Added change company view color

Signed-off-by: Ronni Skansing <rskansing@gmail.com>
This commit is contained in:
Ronni Skansing
2026-06-14 11:49:22 +02:00
parent 63aac09d40
commit 6a3903523b
9 changed files with 239 additions and 27 deletions
+2
View File
@@ -16,6 +16,8 @@ type Company struct {
UpdatedAt *time.Time `gorm:"not null;index"`
Name string `gorm:"not null;unique;index"`
Comment *string `gorm:"type:text"`
// Color is an optional #RGB or #RRGGBB used to tint the company view banner and frame
Color *string `gorm:"type:text"`
// backref: many-to-one
Users []*User //`gorm:"foreignKey:CompanyID;"`
+7
View File
@@ -16,6 +16,7 @@ type Company struct {
UpdatedAt *time.Time `json:"updatedAt"`
Name nullable.Nullable[vo.String64] `json:"name"`
Comment nullable.Nullable[vo.OptionalString1MB] `json:"comment"`
Color nullable.Nullable[vo.OptionalHexColor] `json:"color"`
}
// Validate checks if the Company configuration with a valid state
@@ -43,5 +44,11 @@ func (c *Company) ToDBMap() map[string]any {
m["comment"] = comment.String()
}
}
if c.Color.IsSpecified() {
m["color"] = nil
if color, err := c.Color.Get(); err == nil {
m["color"] = color.String()
}
}
return m
}
+7
View File
@@ -152,11 +152,18 @@ func ToCompany(row *database.Company) *model.Company {
if row.Comment != nil {
comment = nullable.NewNullableWithValue(*vo.NewUnsafeOptionalString1MB(*row.Comment))
}
var color nullable.Nullable[vo.OptionalHexColor]
if row.Color != nil {
if c, err := vo.NewOptionalHexColor(*row.Color); err == nil {
color = nullable.NewNullableWithValue(*c)
}
}
return &model.Company{
ID: id,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
Name: name,
Comment: comment,
Color: color,
}
}
+3
View File
@@ -230,6 +230,9 @@ func (s *Company) UpdateByID(
if v, err := company.Comment.Get(); err == nil {
current.Comment.Set(v)
}
if company.Color.IsSpecified() {
current.Color = company.Color
}
// validate
if err := company.Validate(); err != nil {
s.Logger.Errorw("failed to validate company", "error", err)
+61
View File
@@ -3,6 +3,7 @@ package vo
import (
"encoding/json"
"fmt"
"regexp"
"strconv"
"strings"
@@ -177,6 +178,66 @@ func (s *OptionalString64) UnmarshalJSON(data []byte) error {
return nil
}
// hexColorPattern matches a #RGB or #RRGGBB color
var hexColorPattern = regexp.MustCompile(`^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$`)
// OptionalHexColor is an empty string or a #RGB / #RRGGBB hex color.
// it is constrained so the value is safe to render into a style attribute
type OptionalHexColor struct {
inner string
}
// NewOptionalHexColor creates a new optional hex color
func NewOptionalHexColor(s string) (*OptionalHexColor, error) {
s = strings.TrimSpace(s)
if s != "" && !hexColorPattern.MatchString(s) {
return nil, errors.New("invalid hex color")
}
return &OptionalHexColor{
inner: strings.ToLower(s),
}, nil
}
// NewOptionalHexColorMust creates a new optional hex color and panics if it fails
func NewOptionalHexColorMust(s string) *OptionalHexColor {
a, err := NewOptionalHexColor(s)
if err != nil {
panic(err)
}
return a
}
// NewEmptyOptionalHexColor creates a new empty optional hex color
func NewEmptyOptionalHexColor() *OptionalHexColor {
return &OptionalHexColor{
inner: "",
}
}
// String returns the string representation of the hex color
func (s OptionalHexColor) String() string {
return s.inner
}
// MarshalJSON implements the json.Marshaler interface
func (s OptionalHexColor) MarshalJSON() ([]byte, error) {
return json.Marshal(s.inner)
}
// UnmarshalJSON unmarshals the json into a hex color
func (s *OptionalHexColor) UnmarshalJSON(data []byte) error {
var str string
if err := json.Unmarshal(data, &str); err != nil {
return err
}
ss, err := NewOptionalHexColor(str)
if err != nil {
return unwrapError(err)
}
s.inner = ss.inner
return nil
}
// OptionalString127 is a trimmed string with a min of 0 and a max of 127
type OptionalString127 struct {
inner string
+8 -3
View File
@@ -1243,13 +1243,18 @@ export class API {
* @param {string} id
* @param {string} name
* @param {string} comment
* @param {string} [color] #RGB or #RRGGBB, empty string clears it
* @returns {Promise<ApiResponse>}
*/
update: async (id, name, comment) => {
return await postJSON(this.getPath(`/company/${id}`), {
update: async (id, name, comment, color) => {
const body = {
name: name,
comment: comment
});
};
if (color !== undefined) {
body.color = color;
}
return await postJSON(this.getPath(`/company/${id}`), body);
},
/**
@@ -1,8 +1,13 @@
<script>
import { AppStateService } from '$lib/service/appState';
import { onMount, onDestroy } from 'svelte';
import { api } from '$lib/api/apiProxy.js';
import { showIsLoading } from '$lib/store/loading';
import { resourceContext } from '$lib/store/resourceContext';
import { companyColorOverride } from '$lib/store/companyColor';
// default banner color, matches the active-blue tailwind token
const DEFAULT_COLOR = '#1e3fa8';
let context = {
current: '',
@@ -10,6 +15,45 @@
companyID: null
};
// custom color of the company currently being viewed, null when none
let companyColor = null;
// company id the color was last loaded for, avoids refetching on every update
let loadedColorForID = null;
// load the company custom color so the banner and frame can be tinted
async function loadCompanyColor(companyID) {
loadedColorForID = companyID;
try {
const res = await api.company.getByID(companyID);
companyColor = res.success && res.data?.color ? res.data.color : null;
} catch (_) {
companyColor = null;
}
}
// expand #rgb to #rrggbb and parse to rgb components
function parseHex(hex) {
let h = hex.replace('#', '');
if (h.length === 3) {
h = h
.split('')
.map((c) => c + c)
.join('');
}
return {
r: parseInt(h.slice(0, 2), 16),
g: parseInt(h.slice(2, 4), 16),
b: parseInt(h.slice(4, 6), 16)
};
}
// pick a readable text color for a given background using relative luminance
function readableTextColor(hex) {
const { r, g, b } = parseHex(hex);
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
return luminance > 0.6 ? '#111827' : '#ffffff';
}
let resource = {
resourceType: null,
resourceCompanyID: null,
@@ -67,6 +111,14 @@
location.reload();
}
// load the color whenever the viewed company changes, clear it in global context
$: if (context.companyID && context.companyID !== loadedColorForID) {
loadCompanyColor(context.companyID);
} else if (!context.companyID) {
companyColor = null;
loadedColorForID = null;
}
$: isCompanyView = context.current === AppStateService.CONTEXT.COMPANY && context.companyName;
$: isResourceActive = resource.isActive;
$: isResourceGlobal = isResourceActive && !resource.resourceCompanyID;
@@ -80,36 +132,47 @@
(!isCompanyView && resource.resourceCompanyID) ||
isResourceInDifferentCompany);
// determine banner style and content
$: bannerStyle = 'bg-active-blue dark:bg-active-blue';
// a live override from company settings takes precedence over the fetched
// color so edits show instantly and saved values do not need a page reload
$: effectiveColor =
$companyColorOverride && $companyColorOverride.companyID === context.companyID
? $companyColorOverride.color
: companyColor;
// the effective color used for the banner and frame
$: activeColor = effectiveColor || DEFAULT_COLOR;
// foreground used for banner text so it stays readable on any color
$: bannerForeground = readableTextColor(activeColor);
// inline styles so a custom company color can override the default
$: bannerStyle = `background-color: ${activeColor}; color: ${bannerForeground};`;
$: frameStyle = `border-color: ${activeColor};`;
</script>
{#if isCompanyView || hasContextMismatch}
<!-- top banner -->
<div class="w-full h-9 {bannerStyle} z-30 company-banner">
<div class="w-full h-9 z-30 company-banner" style={bannerStyle}>
<div class="h-full max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-center gap-4 h-full">
{#if hasContextMismatch}
<!-- context mismatch view -->
<div class="flex items-center space-x-2">
{#if isCompanyView && isResourceGlobal}
<span class="text-white/70 font-medium text-sm">Viewing as</span>
<span class="text-white font-semibold text-sm">{context.companyName}</span>
<span class="text-white/70 font-medium text-sm"></span>
<span class="text-white/90 font-medium text-sm">
<span class="opacity-70 font-medium text-sm">Viewing as</span>
<span class="font-semibold text-sm">{context.companyName}</span>
<span class="opacity-70 font-medium text-sm"></span>
<span class="opacity-90 font-medium text-sm">
This {resource.resourceType || 'resource'} is
<strong class="font-bold">global</strong>
</span>
{:else if !isCompanyView && resource.resourceCompanyID}
<span class="text-white/90 font-medium text-sm">
<span class="opacity-90 font-medium text-sm">
This {resource.resourceType || 'resource'} belongs to
<strong class="font-bold">{resource.resourceCompanyName || 'a company'}</strong>
</span>
{:else if isResourceInDifferentCompany}
<span class="text-white/70 font-medium text-sm">Viewing as</span>
<span class="text-white font-semibold text-sm">{context.companyName}</span>
<span class="text-white/70 font-medium text-sm"></span>
<span class="text-white/90 font-medium text-sm">
<span class="opacity-70 font-medium text-sm">Viewing as</span>
<span class="font-semibold text-sm">{context.companyName}</span>
<span class="opacity-70 font-medium text-sm"></span>
<span class="opacity-90 font-medium text-sm">
This {resource.resourceType || 'resource'} belongs to
<strong class="font-bold"
>{resource.resourceCompanyName || 'another company'}</strong
@@ -120,7 +183,7 @@
<!-- switch button -->
<button
on:click={switchToResourceContext}
class="flex items-center gap-1.5 px-3 py-1 bg-white/20 hover:bg-white/30 text-white rounded text-xs font-semibold transition-colors duration-200"
class="flex items-center gap-1.5 px-3 py-1 bg-white/20 hover:bg-white/30 rounded text-xs font-semibold transition-colors duration-200"
title="Switch to {isResourceGlobal
? 'global'
: resource.resourceCompanyName || 'company'} context"
@@ -139,8 +202,8 @@
{:else}
<!-- normal company view -->
<div class="flex items-center space-x-2">
<span class="text-white/70 font-medium text-sm">Viewing as</span>
<span class="text-white font-semibold text-sm">
<span class="opacity-70 font-medium text-sm">Viewing as</span>
<span class="font-semibold text-sm">
{context.companyName}
</span>
</div>
@@ -148,7 +211,7 @@
<!-- exit button -->
<button
on:click={exitCompanyView}
class="flex items-center gap-1 px-2 py-0.5 text-white/50 hover:text-white/80 text-xs transition-colors duration-200"
class="flex items-center gap-1 px-2 py-0.5 opacity-50 hover:opacity-80 text-xs transition-opacity duration-200"
title="Exit company view"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -168,7 +231,7 @@
<!-- border frame around entire viewport when in company view or context mismatch -->
{#if isCompanyView || hasContextMismatch}
<div class="company-view-frame"></div>
<div class="company-view-frame" style={frameStyle}></div>
{/if}
<style>
@@ -183,11 +246,8 @@
right: 0;
bottom: 0;
border: 3px solid;
border-color: var(--company-frame-color, #1e3fa8);
pointer-events: none;
z-index: 9999;
}
:global(.dark) .company-view-frame {
border-color: #1e3fa8;
}
</style>
+6
View File
@@ -0,0 +1,6 @@
import { writable } from 'svelte/store';
// live override for the company banner color so changes made in company
// settings show in the banner and frame without a page reload
// shape: { companyID: string, color: string } or null
export const companyColorOverride = writable(null);
+64 -3
View File
@@ -1,10 +1,11 @@
<script>
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { onMount, onDestroy } from 'svelte';
import { api } from '$lib/api/apiProxy.js';
import { addToast } from '$lib/store/toast';
import { showIsLoading, hideIsLoading } from '$lib/store/loading.js';
import { companyColorOverride } from '$lib/store/companyColor';
import HeadTitle from '$lib/components/HeadTitle.svelte';
import SettingsCard from '$lib/components/SettingsCard.svelte';
import SettingsLoading from '$lib/components/SettingsLoading.svelte';
@@ -25,13 +26,34 @@
let loaded = false;
let company = null;
// default banner color used when a company has no custom color
const DEFAULT_COMPANY_COLOR = '#1e3fa8';
// general form
let formValues = {
name: '',
comment: ''
comment: '',
color: ''
};
let generalError = '';
let isSaving = false;
// the persisted color, used to revert an unsaved live preview on leave
let savedColor = '';
// push the chosen color to the banner store for a live preview while editing
// only once loaded so the banner keeps its own value until we have the form
$: if (loaded) {
companyColorOverride.set({ companyID: companyId, color: formValues.color });
}
onDestroy(() => {
// drop any unsaved preview, leaving the banner on the persisted color
if (loaded) {
companyColorOverride.set({ companyID: companyId, color: savedColor });
} else {
companyColorOverride.set(null);
}
});
// auto-prune (saved on change, like display mode in settings)
let autoPruneEnabled = false;
@@ -100,6 +122,8 @@
company = res.data;
formValues.name = company.name || '';
formValues.comment = company.comment || '';
formValues.color = company.color || '';
savedColor = formValues.color;
} catch (e) {
addToast('Failed to get company', 'Error');
console.error('failed to get company', e);
@@ -132,7 +156,12 @@
generalError = '';
isSaving = true;
try {
const res = await api.company.update(companyId, formValues.name, formValues.comment);
const res = await api.company.update(
companyId,
formValues.name,
formValues.comment,
formValues.color
);
if (!res.success) {
generalError = res.error;
return;
@@ -247,6 +276,38 @@
class="w-full p-3 rounded-md text-gray-600 dark:text-gray-300 border border-transparent dark:border-gray-700/60 bg-grayblue-light dark:bg-gray-900/60 focus:outline-none focus:border-slate-400 dark:focus:border-highlight-blue/80 focus:bg-gray-100 dark:focus:bg-gray-700/60 resize-y transition-colors duration-200"
/>
</div>
<div class="flex flex-col py-2">
<p class="font-semibold text-slate-600 dark:text-gray-400 py-2">Banner Color</p>
<p class="text-gray-600 dark:text-gray-300 text-sm mb-3">
Tints the banner and frame shown while viewing as this company, making it easier to
recognize which company you are working in.
</p>
<div class="flex items-center gap-3">
<input
type="color"
aria-label="Company banner color"
value={formValues.color || DEFAULT_COMPANY_COLOR}
on:input={(e) => (formValues.color = e.currentTarget.value)}
class="h-9 w-12 rounded-md border border-gray-300 dark:border-gray-700 bg-transparent cursor-pointer"
/>
<input
type="text"
bind:value={formValues.color}
placeholder={DEFAULT_COMPANY_COLOR}
maxlength="7"
class="w-32 p-2 rounded-md text-gray-600 dark:text-gray-300 border border-transparent dark:border-gray-700/60 bg-grayblue-light dark:bg-gray-900/60 focus:outline-none focus:border-slate-400 dark:focus:border-highlight-blue/80 transition-colors duration-200"
/>
{#if formValues.color}
<button
type="button"
on:click={() => (formValues.color = '')}
class="text-sm text-gray-500 dark:text-gray-400 hover:text-cta-blue dark:hover:text-highlight-blue transition-colors"
>
Reset to default
</button>
{/if}
</div>
</div>
<FormError message={generalError} />
<div class="mt-6 flex justify-end">
<FormButton size="medium" isSubmitting={isSaving}>Save Changes</FormButton>