fix(crm): admin surfaces follow the admin light/dark toggle, not the gallery theme (#620)

CRM + accounting admin surfaces read gallery-theme tokens — the
`text-theme`/`text-muted-theme` utility classes and raw `var(--color-surface|
text|surface-border|elevated)` inline styles — which `ThemeContext` writes as
inline `--color-*` on <html> for every route. Those inline vars beat the admin
`.dark` toggle, so e.g. the "Create passive customer" modal (#620) renders dark
while the admin is in light mode (and the reverse).

Repoint every CRM/accounting admin surface to Tailwind `dark:` classes so it
tracks the admin toggle deterministically:
- text-theme → text-neutral-900 dark:text-neutral-100; text-muted-theme →
  text-neutral-500 dark:text-neutral-400 (across the CRM list/detail/editor
  pages, hours, calendar, lineage, installments, CRM settings).
- modal/dropdown/chip/divider inline `var(--color-surface*)` → bg-white
  dark:bg-neutral-900 / border-neutral-200 dark:border-neutral-700 etc.
  (CustomerManagement + CustomerDetail modals = the #620 fix).
- toggle off-track surface-border → bg-neutral-300 dark:bg-neutral-600; brand
  accent ON-state kept (var(--color-accent)).
- CalendarPage FullCalendar chrome: scope local --cal-* vars under .fc / .dark
  .fc so the calendar follows the admin toggle (was reading gallery vars).
- PasswordResetModal bare neutrals + TaxReport Storno/Reissue badges gain dark
  pairings.

Brand accent/primary tokens and the branded admin login are intentionally left
on the gallery theme. The same leak exists in non-CRM admin areas (dashboard,
events) — out of scope here.
This commit is contained in:
Luca
2026-06-16 14:54:25 +02:00
parent 03fa3d8296
commit d3266a0d1c
23 changed files with 192 additions and 197 deletions
@@ -120,10 +120,10 @@ export const CustomerAccountPicker: React.FC<Props> = ({ value, onChange, disabl
return (
<div ref={containerRef} className="relative">
<label className="block text-sm font-medium text-theme mb-1">
<label className="block text-sm font-medium text-neutral-900 dark:text-neutral-100 mb-1">
{t('events.customerPicker.label', 'Customer accounts')}
</label>
<p className="text-xs text-muted-theme mb-2">{helpText}</p>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mb-2">{helpText}</p>
{/* Selected chips */}
{value.length > 0 && (
@@ -131,16 +131,11 @@ export const CustomerAccountPicker: React.FC<Props> = ({ value, onChange, disabl
{value.map((c) => (
<span
key={c.id}
className="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs"
style={{
backgroundColor: 'var(--color-elevated, #f5f5f5)',
color: 'var(--color-text)',
border: '1px solid var(--color-surface-border, #e5e5e5)',
}}
className="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs bg-neutral-100 dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 border border-neutral-200 dark:border-neutral-700"
>
<span className="font-medium">{c.displayName?.trim() || c.email}</span>
{c.displayName?.trim() && c.email !== c.displayName && (
<span className="text-muted-theme">· {c.email}</span>
<span className="text-neutral-500 dark:text-neutral-400">· {c.email}</span>
)}
{!disabled && (
<button
@@ -174,18 +169,14 @@ export const CustomerAccountPicker: React.FC<Props> = ({ value, onChange, disabl
{/* Dropdown */}
{isOpen && query.trim() !== '' && (
<div
className="absolute left-0 right-0 mt-1 z-20 rounded-lg shadow-lg border max-h-72 overflow-y-auto"
style={{
backgroundColor: 'var(--color-surface, #ffffff)',
borderColor: 'var(--color-surface-border, #e5e5e5)',
}}
className="absolute left-0 right-0 mt-1 z-20 rounded-lg shadow-lg border max-h-72 overflow-y-auto bg-white dark:bg-neutral-900 border-neutral-200 dark:border-neutral-700"
>
{isSearching ? (
<div className="px-3 py-3 text-sm text-muted-theme">
<div className="px-3 py-3 text-sm text-neutral-500 dark:text-neutral-400">
{t('events.customerPicker.searching', 'Searching…')}
</div>
) : results.length === 0 ? (
<div className="px-3 py-3 text-sm text-muted-theme">
<div className="px-3 py-3 text-sm text-neutral-500 dark:text-neutral-400">
{t('events.customerPicker.noResults', 'No matches. Invite this customer from Clients → Accounts first.')}
</div>
) : (
@@ -197,7 +188,7 @@ export const CustomerAccountPicker: React.FC<Props> = ({ value, onChange, disabl
onClick={() => select(r)}
className="w-full text-left px-3 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-neutral-700 flex items-center gap-2"
>
<UserPlus className="w-4 h-4 text-muted-theme flex-shrink-0" />
<UserPlus className="w-4 h-4 text-neutral-500 dark:text-neutral-400 flex-shrink-0" />
<span className="flex-1 truncate">{labelFor(r)}</span>
</button>
</li>
@@ -57,7 +57,7 @@ const QuotesPanel: React.FC<Props> = ({ customerAccountId }) => {
return (
<Card padding="lg">
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold text-theme flex items-center gap-2">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 flex items-center gap-2">
<FileText className="w-5 h-5" /> {t('customers.detail.quotesSection', 'Quotes')}
</h2>
<div className="flex gap-2">
@@ -78,18 +78,18 @@ const QuotesPanel: React.FC<Props> = ({ customerAccountId }) => {
</div>
{isLoading ? <Loading /> : !data || data.quotes.length === 0 ? (
<p className="text-sm text-muted-theme">
<p className="text-sm text-neutral-500 dark:text-neutral-400">
{t('customers.detail.noQuotes', 'No quotes for this customer yet.')}
</p>
) : (
<ul className="divide-y" style={{ borderColor: 'var(--color-surface-border)' }}>
<ul className="divide-y divide-neutral-200 dark:divide-neutral-700">
{data.quotes.map((q) => (
<li key={q.id} className="py-2 flex items-center justify-between gap-3">
<div className="min-w-0 flex-1">
<Link to={`/admin/clients/quotes/${q.id}`} className="text-theme hover:underline font-mono text-sm">
<Link to={`/admin/clients/quotes/${q.id}`} className="text-neutral-900 dark:text-neutral-100 hover:underline font-mono text-sm">
{q.quoteNumber}
</Link>
<span className="text-xs text-muted-theme ml-2">{q.eventName || fmtDate(q.issueDate)}</span>
<span className="text-xs text-neutral-500 dark:text-neutral-400 ml-2">{q.eventName || fmtDate(q.issueDate)}</span>
</div>
<span className="text-sm tabular-nums">{formatMoney(Number(q.totalAmountMinor) / 100, q.currency)}</span>
<span className={`px-2 py-0.5 rounded text-xs font-medium ${
@@ -118,7 +118,7 @@ const ContractsPanel: React.FC<Props> = ({ customerAccountId }) => {
return (
<Card padding="lg">
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold text-theme flex items-center gap-2">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 flex items-center gap-2">
<ScrollText className="w-5 h-5" /> {t('customers.detail.contractsSection', 'Contracts')}
</h2>
<div className="flex gap-2">
@@ -132,18 +132,18 @@ const ContractsPanel: React.FC<Props> = ({ customerAccountId }) => {
</div>
{isLoading ? <Loading /> : !data || data.contracts.length === 0 ? (
<p className="text-sm text-muted-theme">
<p className="text-sm text-neutral-500 dark:text-neutral-400">
{t('customers.detail.noContracts', 'No contracts for this customer yet.')}
</p>
) : (
<ul className="divide-y" style={{ borderColor: 'var(--color-surface-border)' }}>
<ul className="divide-y divide-neutral-200 dark:divide-neutral-700">
{data.contracts.map((c) => (
<li key={c.id} className="py-2 flex items-center justify-between gap-3">
<div className="min-w-0 flex-1">
<Link to={`/admin/clients/contracts/${c.id}`} className="text-theme hover:underline font-mono text-sm">
<Link to={`/admin/clients/contracts/${c.id}`} className="text-neutral-900 dark:text-neutral-100 hover:underline font-mono text-sm">
{c.contractNumber}
</Link>
<span className="text-xs text-muted-theme ml-2 truncate">{c.title || fmtDate(c.issueDate)}</span>
<span className="text-xs text-neutral-500 dark:text-neutral-400 ml-2 truncate">{c.title || fmtDate(c.issueDate)}</span>
</div>
<span className={`px-2 py-0.5 rounded text-xs font-medium ${
c.status === 'fully_signed' ? 'bg-green-100 text-green-800'
@@ -172,7 +172,7 @@ const InvoicesPanel: React.FC<Props> = ({ customerAccountId }) => {
return (
<Card padding="lg">
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold text-theme flex items-center gap-2">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 flex items-center gap-2">
<Receipt className="w-5 h-5" /> {t('customers.detail.billsSection', 'Invoices')}
</h2>
<div className="flex gap-2">
@@ -187,18 +187,18 @@ const InvoicesPanel: React.FC<Props> = ({ customerAccountId }) => {
</div>
{isLoading ? <Loading /> : !data || data.invoices.length === 0 ? (
<p className="text-sm text-muted-theme">
<p className="text-sm text-neutral-500 dark:text-neutral-400">
{t('customers.detail.noBills', 'No invoices for this customer yet.')}
</p>
) : (
<ul className="divide-y" style={{ borderColor: 'var(--color-surface-border)' }}>
<ul className="divide-y divide-neutral-200 dark:divide-neutral-700">
{data.invoices.map((inv) => (
<li key={inv.id} className="py-2 flex items-center justify-between gap-3">
<div className="min-w-0 flex-1">
<Link to={`/admin/clients/bills/${inv.id}`} className="text-theme hover:underline font-mono text-sm">
<Link to={`/admin/clients/bills/${inv.id}`} className="text-neutral-900 dark:text-neutral-100 hover:underline font-mono text-sm">
{inv.invoiceNumber}
</Link>
<span className="text-xs text-muted-theme ml-2">
<span className="text-xs text-neutral-500 dark:text-neutral-400 ml-2">
{fmtDate(inv.dueDate)}
{inv.installmentTotal > 1 ? ` · ${inv.installmentIndex + 1}/${inv.installmentTotal}` : ''}
</span>
@@ -62,8 +62,8 @@ const Toggle: React.FC<ToggleProps> = ({ enabled, onChange, label, hint, icon: I
role="switch"
aria-checked={enabled}
onClick={onChange}
className="relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
style={{ backgroundColor: enabled ? 'var(--color-accent, #5C8762)' : '#cbd5e1' }}
className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 ${enabled ? '' : 'bg-neutral-300 dark:bg-neutral-600'}`}
style={enabled ? { backgroundColor: 'var(--color-accent, #5C8762)' } : undefined}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${enabled ? 'translate-x-6' : 'translate-x-1'}`}
@@ -91,7 +91,7 @@ export const DocumentLineageCard: React.FC<DocumentLineageCardProps> = ({
if (isLoading) {
return (
<Card padding="md" className={className}>
<p className="text-sm text-muted-theme">
<p className="text-sm text-neutral-500 dark:text-neutral-400">
{t('dealLineage.loading', 'Loading related documents…')}
</p>
</Card>
@@ -128,7 +128,7 @@ export const DocumentLineageCard: React.FC<DocumentLineageCardProps> = ({
<h2 className="font-semibold mb-1 flex items-center gap-2">
{t('dealLineage.title', 'Related documents')}
</h2>
<p className="text-xs text-muted-theme">
<p className="text-xs text-neutral-500 dark:text-neutral-400">
{t('dealLineage.empty', 'No other documents share this deal yet. New invoices, contracts, or installments will show up here once created.')}
</p>
</Card>
@@ -256,7 +256,7 @@ const Group: React.FC<{
action?: React.ReactNode;
}> = ({ icon, label, count, children, action }) => (
<div className="mb-3 last:mb-0">
<div className="flex items-center gap-2 text-xs uppercase tracking-wider text-muted-theme mb-1">
<div className="flex items-center gap-2 text-xs uppercase tracking-wider text-neutral-500 dark:text-neutral-400 mb-1">
{icon}
<span>{label}</span>
<span>({count})</span>
@@ -282,19 +282,19 @@ const Row: React.FC<{
const inner = (
<div className="flex items-center justify-between gap-3 py-1.5">
<div className="flex items-center gap-2 min-w-0">
<span className={`font-mono text-sm ${isCurrent ? 'text-muted-theme' : ''}`}>{number}</span>
<span className={`font-mono text-sm ${isCurrent ? 'text-neutral-500 dark:text-neutral-400' : ''}`}>{number}</span>
{badge && (
<span className="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded font-semibold bg-purple-100 text-purple-800 dark:bg-purple-900/40 dark:text-purple-300">
{badge}
</span>
)}
{meta && (
<span className="text-xs text-muted-theme truncate">{meta}</span>
<span className="text-xs text-neutral-500 dark:text-neutral-400 truncate">{meta}</span>
)}
</div>
<div className="flex items-center gap-2 shrink-0 text-xs">
{right && <span className="tabular-nums">{right}</span>}
<span className="text-muted-theme">{t(statusKey, statusFallback)}</span>
<span className="text-neutral-500 dark:text-neutral-400">{t(statusKey, statusFallback)}</span>
</div>
</div>
);
@@ -153,7 +153,7 @@ export const EditInstallmentPlanModal: React.FC<EditInstallmentPlanModalProps> =
<h2 className="text-xl font-semibold">
{t('dealLineage.editPlanModalTitle', 'Edit installment plan')}
</h2>
<p className="text-xs text-muted-theme mt-1">
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{t('dealLineage.editPlanHelp',
'Atomically reshape this plan: change percents, labels, triggers, add or remove rows. The plan total stays fixed; existing invoice numbers are kept where possible. Refused once any invoice has shipped.')}
</p>
@@ -177,7 +177,7 @@ export const InstallmentsPanel: React.FC<InstallmentsPanelProps> = ({
{enabled && (
<>
<p className="text-xs text-muted-theme mb-3">
<p className="text-xs text-neutral-500 dark:text-neutral-400 mb-3">
{advanced
? t('installments.advancedHint',
'Pick a trigger (quote accepted, before/after event, on delivery, fixed date) plus offset in days. Triggers re-resolve if the event date later shifts.')
@@ -192,7 +192,7 @@ export const InstallmentsPanel: React.FC<InstallmentsPanelProps> = ({
className="grid grid-cols-12 gap-2 items-end p-2 rounded-md bg-neutral-50 dark:bg-neutral-800/40"
>
<div className="col-span-2">
<label className="block text-xs text-muted-theme mb-1">
<label className="block text-xs text-neutral-500 dark:text-neutral-400 mb-1">
{t('installments.percent', '%')}
</label>
<Input
@@ -206,7 +206,7 @@ export const InstallmentsPanel: React.FC<InstallmentsPanelProps> = ({
/>
</div>
<div className="col-span-4">
<label className="block text-xs text-muted-theme mb-1">
<label className="block text-xs text-neutral-500 dark:text-neutral-400 mb-1">
{t('installments.label', 'Label')}
</label>
<Input
@@ -219,11 +219,11 @@ export const InstallmentsPanel: React.FC<InstallmentsPanelProps> = ({
{!advanced ? (
<div className="col-span-5">
<label className="block text-xs text-muted-theme mb-1">
<label className="block text-xs text-neutral-500 dark:text-neutral-400 mb-1">
{t('installments.sendOn', 'Send on')}
</label>
{row.trigger === 'after_delivery' ? (
<div className="text-xs text-muted-theme py-2">
<div className="text-xs text-neutral-500 dark:text-neutral-400 py-2">
{t('installments.onDeliveryHint',
'On delivery — admin releases manually. Switch to advanced to change.')}
</div>
@@ -242,7 +242,7 @@ export const InstallmentsPanel: React.FC<InstallmentsPanelProps> = ({
) : (
<>
<div className="col-span-3">
<label className="block text-xs text-muted-theme mb-1">
<label className="block text-xs text-neutral-500 dark:text-neutral-400 mb-1">
{t('installments.trigger', 'Trigger')}
</label>
<select
@@ -262,7 +262,7 @@ export const InstallmentsPanel: React.FC<InstallmentsPanelProps> = ({
</select>
</div>
<div className="col-span-2">
<label className="block text-xs text-muted-theme mb-1">
<label className="block text-xs text-neutral-500 dark:text-neutral-400 mb-1">
{t('installments.offsetDays', 'Offset (days)')}
</label>
<Input
@@ -87,12 +87,12 @@ export const PasswordResetModal: React.FC<PasswordResetModalProps> = ({
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<Card className="max-w-md w-full">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-neutral-900">
<h2 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100">
{resultPassword ? t('events.passwordReset.newTitle') : t('events.passwordReset.title')}
</h2>
<button
onClick={onClose}
className="text-neutral-400 hover:text-neutral-600"
className="text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300"
>
<X className="w-5 h-5" />
</button>
@@ -163,12 +163,12 @@ export const PasswordResetModal: React.FC<PasswordResetModalProps> = ({
type="checkbox"
checked={sendEmail}
onChange={(e) => setSendEmail(e.target.checked)}
className="w-4 h-4 text-accent bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500 focus:ring-2"
className="w-4 h-4 text-accent bg-neutral-100 dark:bg-neutral-700 border-neutral-300 dark:border-neutral-600 rounded focus:ring-primary-500 focus:ring-2"
/>
<div className="flex-1">
<div className="flex items-center gap-2">
<Mail className="w-4 h-4 text-neutral-500" />
<span className="text-sm font-medium text-neutral-700">
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
{t('events.passwordReset.sendEmail')}
</span>
</div>