Files
picpeak/frontend/src/components/admin/EditInstallmentPlanModal.tsx
T
Luca d3266a0d1c 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.
2026-06-16 14:54:25 +02:00

210 lines
8.2 KiB
TypeScript

/**
* <EditInstallmentPlanModal> — atomic reshape of an installment plan
* after siblings have spawned. Wraps the same `<InstallmentsPanel>`
* used by Quote/Bill editors, pre-filled from the existing siblings
* (via the lineage payload — no extra fetch needed).
*
* Triggered from `<DocumentLineageCard>`, which only renders the
* "Edit plan" button when every invoice on the deal is still
* scheduled / pending_delivery (the same gate enforced server-side).
* The server still re-checks on save; a 409 INVOICE_LOCKED races back
* if a sibling shipped between modal open and save click.
*
* Plan total preservation: the backend uses sum(existing sibling totals)
* as the plan total, so the panel doesn't need to surface money — only
* the structure (percents / labels / triggers / offsets).
*/
import React, { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'react-toastify';
import { X, AlertTriangle } from 'lucide-react';
import { Button, Card } from '../common';
import { InstallmentsPanel } from './InstallmentsPanel';
import type { PaymentTermInstallment } from '../../services/quotes.service';
import { dealsService } from '../../services/deals.service';
export interface EditInstallmentPlanModalProps {
isOpen: boolean;
onClose: () => void;
dealUuid: string;
/** Existing sibling invoices on the deal — used to seed the panel
* and to derive the current plan total for percent computation. */
siblings: Array<{
id: number;
number: string;
status: string;
totalAmountMinor?: number;
installmentIndex?: number;
installmentTotal?: number;
installmentLabel?: string | null;
installmentTrigger?: string | null;
installmentOffsetDays?: number;
}>;
/** Event date — passed to <InstallmentsPanel> so its date preview
* works for before_event / after_event rows. */
eventDate?: string | null;
onSaved?: () => void;
}
const VALID_TRIGGERS: PaymentTermInstallment['trigger'][] = [
'quote_accepted', 'before_event', 'after_event', 'after_delivery', 'fixed_date',
];
function isValidTrigger(t: unknown): t is PaymentTermInstallment['trigger'] {
return typeof t === 'string' && (VALID_TRIGGERS as string[]).includes(t);
}
export const EditInstallmentPlanModal: React.FC<EditInstallmentPlanModalProps> = ({
isOpen, onClose, dealUuid, siblings, eventDate, onSaved,
}) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
// Derive the initial panel rows from the existing siblings, sorted
// by installment_index. Percent = total_amount_minor / sum * 100.
const initialPlan = useMemo<PaymentTermInstallment[]>(() => {
const sorted = [...siblings].sort(
(a, b) => (a.installmentIndex ?? 0) - (b.installmentIndex ?? 0),
);
const sum = sorted.reduce((s, x) => s + (x.totalAmountMinor || 0), 0);
if (sum === 0) {
// Degenerate: every sibling totals zero. Fall back to equal split.
return sorted.map((s, i) => ({
label: s.installmentLabel || `${i + 1}/${sorted.length}`,
percent: Math.round(10000 / sorted.length) / 100,
trigger: isValidTrigger(s.installmentTrigger) ? s.installmentTrigger : 'fixed_date',
offset_days: s.installmentOffsetDays ?? 0,
}));
}
// Compute percents, last slice absorbs rounding so the panel reports
// sum=100 on open.
const rows: PaymentTermInstallment[] = [];
let accPct = 0;
sorted.forEach((s, i) => {
let pct = i === sorted.length - 1
? Math.max(0, 100 - accPct)
: Math.round(((s.totalAmountMinor || 0) / sum) * 10000) / 100;
pct = Math.round(pct * 100) / 100;
accPct += pct;
rows.push({
label: s.installmentLabel || `${i + 1}/${sorted.length}`,
percent: pct,
trigger: isValidTrigger(s.installmentTrigger) ? s.installmentTrigger : 'fixed_date',
offset_days: s.installmentOffsetDays ?? 0,
});
});
return rows;
}, [siblings]);
const [plan, setPlan] = useState<PaymentTermInstallment[] | null>(initialPlan);
const [valid, setValid] = useState(true);
// Reseed when the modal opens against fresh siblings.
useEffect(() => {
if (isOpen) setPlan(initialPlan);
}, [isOpen, initialPlan]);
const save = useMutation({
mutationFn: async () => {
if (!plan || plan.length === 0) {
throw new Error(t('dealLineage.editPlanEmpty', 'Plan cannot be empty.') as string);
}
return dealsService.updateInstallmentPlan(dealUuid, plan);
},
onSuccess: () => {
toast.success(t('dealLineage.editPlanSuccess', 'Installment plan updated.'));
queryClient.invalidateQueries({ queryKey: ['deal-lineage', dealUuid] });
queryClient.invalidateQueries({ queryKey: ['adminBills'] });
queryClient.invalidateQueries({ queryKey: ['admin-invoices'] });
onSaved?.();
onClose();
},
onError: (err: unknown) => {
const e = err as { response?: { data?: { error?: string; code?: string } }; message?: string };
const code = e?.response?.data?.code;
if (code === 'INVOICE_LOCKED' || code === 'PLAN_HAS_STORNO') {
toast.error(t('dealLineage.editPlanLocked',
'Plan can no longer be edited — at least one invoice has shipped or been cancelled.'));
queryClient.invalidateQueries({ queryKey: ['deal-lineage', dealUuid] });
onClose();
return;
}
if (code === 'PERCENT_SUM_INVALID') {
toast.error(t('dealLineage.editPlanPercentSumError', 'Percents must sum to 100.'));
return;
}
toast.error(
e?.response?.data?.error
|| e?.message
|| t('dealLineage.editPlanGeneralError', 'Could not update plan.'),
);
},
});
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<Card className="max-w-3xl w-full max-h-[90vh] overflow-y-auto">
<div className="flex items-start justify-between gap-3 mb-2 flex-wrap">
<div>
<h2 className="text-xl font-semibold">
{t('dealLineage.editPlanModalTitle', 'Edit installment plan')}
</h2>
<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>
</div>
<button
type="button"
onClick={onClose}
disabled={save.isPending}
className="text-neutral-400 hover:text-neutral-600 disabled:opacity-50"
aria-label={t('common.close', 'Close') as string}
>
<X className="w-5 h-5" />
</button>
</div>
<div className="rounded-md border border-amber-300 bg-amber-50 dark:bg-amber-900/20 dark:border-amber-700 p-3 mb-3 flex items-start gap-2">
<AlertTriangle className="w-4 h-4 text-amber-700 dark:text-amber-300 mt-0.5 shrink-0" />
<p className="text-sm text-amber-800 dark:text-amber-200">
{t('dealLineage.editPlanWarning',
'Trimming rows deletes their invoice numbers (the sequence cannot release them — a §14 UStG continuity rule). Adding rows claims fresh numbers.')}
</p>
</div>
<InstallmentsPanel
value={plan}
onChange={(next) => setPlan(next || [])}
onValidityChange={setValid}
eventDate={eventDate || null}
/>
<div className="flex justify-end gap-2 mt-4">
<Button
variant="outline"
onClick={onClose}
disabled={save.isPending}
>
{t('common.cancel', 'Cancel')}
</Button>
<Button
variant="primary"
onClick={() => save.mutate()}
isLoading={save.isPending}
disabled={save.isPending || !valid || !plan || plan.length === 0}
>
{t('dealLineage.editPlanSave', 'Save plan')}
</Button>
</div>
</Card>
</div>
);
};
export default EditInstallmentPlanModal;