feat(crm): frontend code — pages + services + components
Brings in the full frontend CRM stack: admin authoring pages,
customer-portal surfaces, public response flows, typed services,
and the supporting component library. i18n locale JSON is the next
commit (kept separate so reviewers can read it as data).
Pages
- Quotes: list / editor / detail / public accept-decline
- Invoices: list / editor / detail / public payment-check
- Contracts: list / editor / detail / block library / public sign
- Calendar (FullCalendar — admin-only v1)
- Tax report (period picker + CSV/PDF export)
- Hours (logged time entries, per-customer)
- Deals lineage (DocumentLineageCard surfaces)
- CRM Development (admin dev tools, gated by crmDevelopment flag)
- Customer-portal pages for quotes / invoices / contracts
- Settings reorg: CRM-Settings group + dedicated tabs for Business
Profile, CRM behaviour, Contracts block library, Reminder emails
- BrandingPage typography (PDF font picker)
- EventDetailsPage / CustomerDetailPage / CreateEventPage extensions
(event-time fields, hours toggle, per-event reminder override)
Services (typed)
- quotes.service, bills.service, contracts.service
- customerAdmin.service, deals.service, calendar.service,
taxReport.service, contracts-blocks.service
- businessProfile.service (timezone, font picker, bank accounts)
- useInstallmentDefaults hook, useLocalizedDate dateInputLang extension
Components (admin)
- CustomerPicker (shared across quote/invoice/contract editors)
- LineItemsTable (hierarchy + details_text, memoised pricing)
- InstallmentsPanel (simple + advanced toggle, fixed-date vs trigger)
- DocumentLineageCard (deal_uuid grouped view)
- EditInstallmentPlanModal (atomic post-spawn plan reshape)
- EventReminderOverrideCard, EmailTemplateEditor (tiptap),
PdfFontPicker, IntegrityCheckCard
- Feature-flag context + RequireFeature wrapper + AdminSidebar
featureFlagsAny derivation + UI-hiding sweep
Build infra
- vite.config: fullcalendar chunk carved off (~200 KB lazy-loaded)
- frontend/package.json: tiptap, fullcalendar, signature_pad,
react-international-phone, et al.
- tailwind + prose styles updated for editor surfaces
3-way merge note: 1 conflict (CustomerDetailPage.tsx) hand-resolved
to keep upstream's SUPPORTED_LANGUAGES.map() data-driven pattern
over feat/crm's hardcoded option list; feat/crm's DecimalInput
import preserved alongside.
This commit is contained in:
@@ -6,6 +6,7 @@ import { Loading } from '../common';
|
||||
import { guestsService, AdminGuest } from '../../services/guests.service';
|
||||
import { AuthenticatedImage } from '../common/AuthenticatedImage';
|
||||
import { buildResourceUrl } from '../../utils/url';
|
||||
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
||||
|
||||
interface AdminGuestDetailProps {
|
||||
eventId: number;
|
||||
@@ -17,6 +18,7 @@ type Tab = 'all' | 'liked' | 'favorited' | 'rated' | 'commented';
|
||||
|
||||
export const AdminGuestDetail: React.FC<AdminGuestDetailProps> = ({ eventId, guest, onClose }) => {
|
||||
const { t } = useTranslation();
|
||||
const { formatDateTime: fmtDateTime } = useLocalizedDate();
|
||||
const [tab, setTab] = useState<Tab>('all');
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
@@ -159,7 +161,7 @@ export const AdminGuestDetail: React.FC<AdminGuestDetailProps> = ({ eventId, gue
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{c.photo.filename} · {new Date(c.created_at).toLocaleString()}
|
||||
{c.photo.filename} · {fmtDateTime(c.created_at)}
|
||||
</div>
|
||||
<p className="text-sm text-neutral-900 dark:text-neutral-100 mt-1">{c.comment}</p>
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { AdminGuestDetail } from './AdminGuestDetail';
|
||||
import { GuestSelectionsAggregate } from './GuestSelectionsAggregate';
|
||||
import { GuestInviteDialog } from './GuestInviteDialog';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
||||
|
||||
interface AdminGuestsListProps {
|
||||
eventId: number;
|
||||
@@ -18,6 +19,7 @@ type View = 'list' | 'aggregate';
|
||||
|
||||
export const AdminGuestsList: React.FC<AdminGuestsListProps> = ({ eventId, eventName }) => {
|
||||
const { t } = useTranslation();
|
||||
const { format: fmtDate } = useLocalizedDate();
|
||||
const queryClient = useQueryClient();
|
||||
const [view, setView] = useState<View>('list');
|
||||
const [selectedGuest, setSelectedGuest] = useState<AdminGuest | null>(null);
|
||||
@@ -271,7 +273,7 @@ export const AdminGuestsList: React.FC<AdminGuestsListProps> = ({ eventId, event
|
||||
{guest.stats.ratings}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-neutral-600 dark:text-neutral-400">
|
||||
{new Date(guest.last_seen_at).toLocaleDateString()}
|
||||
{fmtDate(guest.last_seen_at)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
|
||||
@@ -35,10 +35,30 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
|
||||
const companyName = brandingSettings?.branding_company_name?.trim() || 'PicPeak';
|
||||
const logoUrl = brandingSettings?.branding_logo_url?.trim();
|
||||
const logoDisplayMode = brandingSettings?.branding_logo_display_mode || 'logo_and_text';
|
||||
// Logo placement honours the same Branding > Logo Position setting
|
||||
// the gallery does. 'sidepanel' moves the logo into the AdminSidebar
|
||||
// brand row — suppress it here so it doesn't double up. left /
|
||||
// center / right reposition the logo block within this header bar.
|
||||
const logoPosition = brandingSettings?.branding_logo_position || 'left';
|
||||
const logoInSidebar = logoPosition === 'sidepanel';
|
||||
const resolvedLogoUrl = logoUrl
|
||||
? (logoUrl.startsWith('http') ? logoUrl : buildResourceUrl(logoUrl))
|
||||
: '/picpeak-kamera-transparent.png';
|
||||
|
||||
// Renders the logo + wordmark block per the current logo_display_mode.
|
||||
// Re-used in left / center / right slots below so all three positions
|
||||
// produce visually identical brand chrome.
|
||||
const renderBrandBlock = () => (
|
||||
<div className="flex items-center gap-2">
|
||||
{!logoInSidebar && (logoDisplayMode === 'logo_only' || logoDisplayMode === 'logo_and_text') && (
|
||||
<img src={resolvedLogoUrl} alt={companyName} className="h-8 w-auto object-contain" />
|
||||
)}
|
||||
{(logoDisplayMode === 'text_only' || logoDisplayMode === 'logo_and_text') && (
|
||||
<span className="text-xl sm:text-2xl" style={{ fontFamily: 'Poppins, sans-serif', fontWeight: 600, color: '#145346' }}>{companyName}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const userMenuRef = useRef<HTMLDivElement>(null);
|
||||
const notificationRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -79,10 +99,25 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
|
||||
const unreadCount = notificationsData?.unreadCount || 0;
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-30 bg-white dark:bg-neutral-900 border-b border-neutral-200 dark:border-neutral-700">
|
||||
<div className="px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
{/* Left side - Menu button, Logo, and Date */}
|
||||
// border-b is on the OUTER <header> so it spans the full header
|
||||
// width — putting it on the inner row instead left a 32px gap on
|
||||
// the left (the px-4/sm:px-6/lg:px-8 padding) before the divider
|
||||
// started, visible as a missing segment between the sidebar's
|
||||
// brand-row bottom border and the header's bottom border.
|
||||
//
|
||||
// The outer header is explicitly h-16 with the default border-box,
|
||||
// so the 1px border is painted INSIDE the 64px height (y=63..64)
|
||||
// — same model as the sidebar's brand row (also h-16 border-b
|
||||
// border-box). Both bottom borders meet at the exact same
|
||||
// y-coordinate.
|
||||
<header className="sticky top-0 z-30 bg-white dark:bg-neutral-900 h-16 border-b border-neutral-200 dark:border-neutral-700">
|
||||
<div className="px-4 sm:px-6 lg:px-8 h-full">
|
||||
<div className="relative flex items-center justify-between h-full gap-3">
|
||||
{/* Left side - Menu button, optional left-positioned logo, Date.
|
||||
Logo block appears here when logo_position = 'left' (the
|
||||
default). For 'center' it's absolutely positioned across
|
||||
the whole header; for 'right' it sits in the right-side
|
||||
cluster just before the action widgets. */}
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<button
|
||||
onClick={onMenuClick}
|
||||
@@ -91,26 +126,66 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
|
||||
<Menu className="w-6 h-6" />
|
||||
</button>
|
||||
|
||||
{/* Logo - sticky to the left on all sizes */}
|
||||
<div className="flex items-center gap-2">
|
||||
{(logoDisplayMode === 'logo_only' || logoDisplayMode === 'logo_and_text') && (
|
||||
<img src={resolvedLogoUrl} alt={companyName} className="h-8 w-auto object-contain" />
|
||||
)}
|
||||
{(logoDisplayMode === 'text_only' || logoDisplayMode === 'logo_and_text') && (
|
||||
<span className="text-xl sm:text-2xl" style={{ fontFamily: 'Poppins, sans-serif', fontWeight: 600, color: '#145346' }}>{companyName}</span>
|
||||
)}
|
||||
</div>
|
||||
{!logoInSidebar && logoPosition === 'left' && renderBrandBlock()}
|
||||
{/* Narrow-viewport fallback for center / right positions.
|
||||
On screens where the centered (lg+) or right-anchored
|
||||
(md+) brand block is hidden, render it on the left so
|
||||
the admin chrome doesn't go logo-less on phones. */}
|
||||
{!logoInSidebar && logoPosition === 'center' && (
|
||||
<div className="flex lg:hidden">{renderBrandBlock()}</div>
|
||||
)}
|
||||
{!logoInSidebar && logoPosition === 'right' && (
|
||||
<div className="flex md:hidden">{renderBrandBlock()}</div>
|
||||
)}
|
||||
|
||||
{/* Date display - hidden on smaller screens */}
|
||||
<div className="hidden xl:block pl-3 border-l border-neutral-200 dark:border-neutral-700 ml-1">
|
||||
<p className="text-base text-neutral-700 dark:text-neutral-300">
|
||||
{/* Date display - hidden on smaller screens.
|
||||
The vertical divider + left padding only render when
|
||||
the logo sits on the left of the date (logo_position
|
||||
= 'left'). For 'center' / 'right' / 'sidepanel' the
|
||||
left cluster has only the mobile menu (which is
|
||||
hidden on xl+), so a divider would be floating on
|
||||
its own with nothing to separate. */}
|
||||
{/* Explicit `flex items-center` (not just block) + the
|
||||
self-stretch on the border-l variant so the divider
|
||||
covers the full header row, AND the text baseline sits
|
||||
exactly on the same y-axis as the brand-block / sidebar
|
||||
logo to its left. The previous `hidden xl:block` left
|
||||
the <p> inheriting its block-level vertical position,
|
||||
which read as slightly off-centre next to the larger
|
||||
logo image. */}
|
||||
<div className={`hidden xl:flex items-center self-stretch ml-1 ${
|
||||
logoPosition === 'left' && !logoInSidebar
|
||||
? 'pl-3 border-l border-neutral-200 dark:border-neutral-700'
|
||||
: ''
|
||||
}`}>
|
||||
<p className="text-base leading-none text-neutral-700 dark:text-neutral-300 m-0">
|
||||
{format(new Date(), 'PPPP')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right side actions */}
|
||||
{/* Centered logo. Absolutely positioned so the existing
|
||||
left/right clusters keep their natural sizing; hidden on
|
||||
sub-lg widths to avoid colliding with the right-side
|
||||
action cluster on narrow screens. pointer-events-none on
|
||||
the wrapper passes hover/click through (the logo itself
|
||||
has no interactive children today). */}
|
||||
{!logoInSidebar && logoPosition === 'center' && (
|
||||
<div className="hidden lg:flex absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 pointer-events-none">
|
||||
{renderBrandBlock()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Right side actions (preceded by the brand block when
|
||||
logo_position = 'right'). The right-anchored logo sits
|
||||
before the language / dark-mode / notifications / user
|
||||
cluster so the widgets stay where admins expect them. */}
|
||||
<div className="flex items-center gap-3">
|
||||
{!logoInSidebar && logoPosition === 'right' && (
|
||||
<div className="hidden md:flex mr-1 pr-2 border-r border-neutral-200 dark:border-neutral-700">
|
||||
{renderBrandBlock()}
|
||||
</div>
|
||||
)}
|
||||
{/* Language Selector */}
|
||||
<LanguageSelector />
|
||||
|
||||
|
||||
@@ -9,10 +9,23 @@ import { AdminHeader } from './AdminHeader';
|
||||
import { MaintenanceBanner } from './MaintenanceBanner';
|
||||
import { MandatoryPasswordChangeModal } from './MandatoryPasswordChangeModal';
|
||||
|
||||
const SIDEBAR_COLLAPSED_KEY = 'admin-sidebar-collapsed';
|
||||
|
||||
export const AdminLayout: React.FC = () => {
|
||||
const { isAuthenticated, isLoading, mustChangePassword } = useAdminAuth();
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
|
||||
const [sidebarCollapsed, setSidebarCollapsedState] = useState<boolean>(() => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
return window.localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === '1';
|
||||
});
|
||||
|
||||
const setSidebarCollapsed = (v: boolean) => {
|
||||
setSidebarCollapsedState(v);
|
||||
if (typeof window !== 'undefined') {
|
||||
window.localStorage.setItem(SIDEBAR_COLLAPSED_KEY, v ? '1' : '0');
|
||||
}
|
||||
};
|
||||
|
||||
// Handle session timeout
|
||||
useSessionTimeout();
|
||||
|
||||
@@ -40,6 +53,8 @@ export const AdminLayout: React.FC = () => {
|
||||
<AdminLayoutInner
|
||||
sidebarOpen={sidebarOpen}
|
||||
setSidebarOpen={setSidebarOpen}
|
||||
sidebarCollapsed={sidebarCollapsed}
|
||||
setSidebarCollapsed={setSidebarCollapsed}
|
||||
mustChangePassword={mustChangePassword}
|
||||
/>
|
||||
</FeatureFlagsProvider>
|
||||
@@ -49,10 +64,12 @@ export const AdminLayout: React.FC = () => {
|
||||
interface AdminLayoutInnerProps {
|
||||
sidebarOpen: boolean;
|
||||
setSidebarOpen: (v: boolean) => void;
|
||||
sidebarCollapsed: boolean;
|
||||
setSidebarCollapsed: (v: boolean) => void;
|
||||
mustChangePassword: boolean;
|
||||
}
|
||||
|
||||
const AdminLayoutInner: React.FC<AdminLayoutInnerProps> = ({ sidebarOpen, setSidebarOpen, mustChangePassword }) => {
|
||||
const AdminLayoutInner: React.FC<AdminLayoutInnerProps> = ({ sidebarOpen, setSidebarOpen, sidebarCollapsed, setSidebarCollapsed, mustChangePassword }) => {
|
||||
return (
|
||||
<div className="h-screen bg-neutral-50 dark:bg-neutral-950 flex overflow-hidden">
|
||||
{/* Mandatory Password Change Modal */}
|
||||
@@ -68,21 +85,40 @@ const AdminLayoutInner: React.FC<AdminLayoutInnerProps> = ({ sidebarOpen, setSid
|
||||
|
||||
{/* Sidebar - disabled when password change required */}
|
||||
<div className={mustChangePassword ? 'pointer-events-none opacity-50' : ''}>
|
||||
<AdminSidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} />
|
||||
<AdminSidebar
|
||||
isOpen={sidebarOpen}
|
||||
onClose={() => setSidebarOpen(false)}
|
||||
collapsed={sidebarCollapsed}
|
||||
onToggleCollapse={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex-1 flex flex-col min-w-0 h-screen">
|
||||
{/* Main content. `scrollbar-gutter: stable` on the column itself
|
||||
(via the inline style) reserves the scrollbar gutter once at
|
||||
the column level — so the header sits in the full column
|
||||
width AND lines up with the sidebar's right edge, while
|
||||
<main>'s scroll content honors the same gutter and never
|
||||
shifts when content overflows. Without this, the header and
|
||||
main each made their own decisions about the gutter, leaving
|
||||
a visible ~15px notch on the right edge of the header's
|
||||
border between the column's content area and the scrollbar. */}
|
||||
<div
|
||||
className="flex-1 flex flex-col min-w-0 h-screen overflow-y-auto"
|
||||
style={{ scrollbarGutter: 'stable' }}
|
||||
>
|
||||
{/* Header - disabled when password change required */}
|
||||
<div className={mustChangePassword ? 'pointer-events-none opacity-50' : ''}>
|
||||
<AdminHeader onMenuClick={() => setSidebarOpen(true)} />
|
||||
</div>
|
||||
|
||||
|
||||
{/* Maintenance mode banner */}
|
||||
<MaintenanceBanner />
|
||||
|
||||
{/* Page content - disabled when password change required */}
|
||||
<main id="main-content" className={`flex-1 px-4 sm:px-6 lg:px-8 py-8 overflow-y-auto ${mustChangePassword ? 'opacity-50 pointer-events-none' : ''}`}>
|
||||
{/* Page content - disabled when password change required.
|
||||
overflow moved up to the column so the scrollbar gutter is
|
||||
reserved once at the column level (see above). main now
|
||||
just contributes its content + padding. */}
|
||||
<main id="main-content" className={`flex-1 px-4 sm:px-6 lg:px-8 py-8 ${mustChangePassword ? 'opacity-50 pointer-events-none' : ''}`}>
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
X,
|
||||
Users,
|
||||
Briefcase,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
} from 'lucide-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -16,10 +18,16 @@ import { settingsService } from '../../services/settings.service';
|
||||
import { VersionInfo } from './VersionInfo';
|
||||
import { usePermissions } from '../../contexts/PermissionsContext';
|
||||
import { useFeatureFlags, type FeatureKey } from '../../contexts/FeatureFlagsContext';
|
||||
import { usePublicSettings } from '../../hooks/usePublicSettings';
|
||||
import { buildResourceUrl } from '../../utils/url';
|
||||
|
||||
interface AdminSidebarProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
/** Desktop-only: collapse to icon-rail when true. Persisted by parent. */
|
||||
collapsed?: boolean;
|
||||
/** Desktop-only: toggle for the collapse button rendered in the title bar. */
|
||||
onToggleCollapse?: () => void;
|
||||
}
|
||||
|
||||
interface NavItem {
|
||||
@@ -74,18 +82,45 @@ const navigation: NavItem[] = [
|
||||
permission: 'customers.view',
|
||||
featureFlag: 'clients',
|
||||
// Hide the entry when the parent is on but no sub-feature is —
|
||||
// there's nothing inside ClientsLayout to link to. Add future
|
||||
// sub-flags (calendar, quotes, bills, messaging) here as they
|
||||
// ship; the entry reappears the moment any of them is enabled.
|
||||
featureFlagsAny: ['customerPortal'],
|
||||
// there's nothing inside ClientsLayout to link to. Mirror the same
|
||||
// set used to derive the parent `clients` flag in
|
||||
// FeatureFlagsContext (see clientsDependsOn) so the two checks
|
||||
// can't disagree: any sub-feature on lights up the entry, all off
|
||||
// hides it. Future siblings (e.g. `messaging`) get appended here
|
||||
// AND in the context derivation.
|
||||
featureFlagsAny: [
|
||||
'customerPortal', 'crmDevelopment', 'quotes', 'bills',
|
||||
'taxReport', 'hoursLogging', 'contracts', 'calendar',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const AdminSidebar: React.FC<AdminSidebarProps> = ({ isOpen, onClose }) => {
|
||||
export const AdminSidebar: React.FC<AdminSidebarProps> = ({ isOpen, onClose, collapsed = false, onToggleCollapse }) => {
|
||||
const location = useLocation();
|
||||
const { t } = useTranslation();
|
||||
const { hasPermission } = usePermissions();
|
||||
const { flags } = useFeatureFlags();
|
||||
// Branding lookup for the "logo_position = sidepanel" mode — when
|
||||
// chosen, the logo replaces the "PicPeak Admin" text in the brand
|
||||
// row, and the favicon takes over in the collapsed icon rail.
|
||||
const { data: publicSettings } = usePublicSettings();
|
||||
const logoInSidebar = publicSettings?.branding_logo_position === 'sidepanel';
|
||||
const rawLogoUrl = publicSettings?.branding_logo_url?.trim();
|
||||
const rawFaviconUrl = publicSettings?.branding_favicon_url?.trim();
|
||||
const resolvedLogoUrl = rawLogoUrl
|
||||
? (rawLogoUrl.startsWith('http') ? rawLogoUrl : buildResourceUrl(rawLogoUrl))
|
||||
: null;
|
||||
const resolvedFaviconUrl = rawFaviconUrl
|
||||
? (rawFaviconUrl.startsWith('http') ? rawFaviconUrl : buildResourceUrl(rawFaviconUrl))
|
||||
: null;
|
||||
// In collapsed rail, prefer the favicon (it's already a square,
|
||||
// tight crop). Fall back to the logo when no favicon is set, then
|
||||
// to nothing — better an empty rail than a stretched logo.
|
||||
const sidebarBrandImageUrl = collapsed
|
||||
? (resolvedFaviconUrl || resolvedLogoUrl)
|
||||
: (resolvedLogoUrl || resolvedFaviconUrl);
|
||||
const showLogoBrand = logoInSidebar && !!sidebarBrandImageUrl;
|
||||
const brandAlt = publicSettings?.branding_company_name?.trim() || t('admin.title');
|
||||
|
||||
const filteredNavigation = navigation.filter((item) => {
|
||||
if (item.permission && !hasPermission(item.permission as string)) return false;
|
||||
@@ -101,38 +136,99 @@ export const AdminSidebar: React.FC<AdminSidebarProps> = ({ isOpen, onClose }) =
|
||||
return true;
|
||||
});
|
||||
|
||||
// Desktop width: full nav (w-64) vs icon rail (w-16). Mobile is always
|
||||
// w-64 since the collapse affordance only applies on lg+ viewports.
|
||||
const widthClasses = collapsed ? 'w-64 lg:w-16' : 'w-64';
|
||||
const showLabels = !collapsed;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`fixed inset-y-0 left-0 z-50 w-64 bg-white dark:bg-neutral-900 border-r border-neutral-200 dark:border-neutral-700 transform transition-transform duration-200 ease-in-out lg:relative lg:translate-x-0 lg:h-screen ${
|
||||
// Right edge drawn via box-shadow rather than `border-r` so the
|
||||
// brand row's `border-b` can extend to the sidebar's full width
|
||||
// and meet the header's `border-b` cleanly at the L-junction. A
|
||||
// 1px border-r would shrink the brand row's content by 1px and
|
||||
// leave a visible step in the horizontal divider where the
|
||||
// sidebar meets the main column. Shadow uses the same neutral
|
||||
// border colors so it looks identical to the previous border.
|
||||
className={`fixed inset-y-0 left-0 z-50 ${widthClasses} bg-white dark:bg-neutral-900 shadow-[1px_0_0_0_theme(colors.neutral.200)] dark:shadow-[1px_0_0_0_theme(colors.neutral.700)] transform transition-all duration-200 ease-in-out lg:relative lg:translate-x-0 lg:h-screen ${
|
||||
isOpen ? 'translate-x-0' : '-translate-x-full'
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col h-screen lg:h-full">
|
||||
{/* Brand */}
|
||||
<div className="flex items-center justify-between h-16 px-6 border-b border-neutral-200 dark:border-neutral-700 flex-shrink-0">
|
||||
<div className="flex items-center">
|
||||
<span className="text-xl font-bold text-neutral-900 dark:text-neutral-100">{t('admin.title')}</span>
|
||||
{/* Brand row: title on the left, mobile close (X) on the
|
||||
right. The desktop collapse toggle used to live here but
|
||||
was moved down next to the version / storage widgets so
|
||||
it sits in admins' muscle-memory zone for chrome controls.
|
||||
When collapsed on desktop the title hides and the row
|
||||
becomes an empty spacer (no rail-width fight). */}
|
||||
<div className={`flex items-center h-16 border-b border-neutral-200 dark:border-neutral-700 flex-shrink-0 ${
|
||||
collapsed ? 'lg:justify-center lg:px-2 px-6 justify-between' : 'justify-between px-6'
|
||||
}`}>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{showLogoBrand ? (
|
||||
<>
|
||||
{/* Logo brand variant — fed by Branding > Logo
|
||||
Position = "Sidebar". On the collapsed rail, only
|
||||
the favicon (or logo as fallback) is shown — sized
|
||||
to fit the 64px-wide rail. Expanded shows the full
|
||||
logo at the same h-8 the admin header uses for
|
||||
visual continuity. */}
|
||||
<img
|
||||
src={sidebarBrandImageUrl!}
|
||||
alt={brandAlt}
|
||||
className={collapsed ? 'h-8 w-8 object-contain lg:h-9 lg:w-9' : 'h-8 w-auto object-contain max-w-full'}
|
||||
/>
|
||||
{/* On mobile the rail-narrow style only applies at
|
||||
lg+, so when collapsed=true the mobile view still
|
||||
has the regular w-64 width — show the company name
|
||||
next to the logo so the brand row doesn't feel
|
||||
empty there. */}
|
||||
{collapsed && (
|
||||
<span className="text-xl font-bold text-neutral-900 dark:text-neutral-100 lg:hidden truncate">
|
||||
{brandAlt}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{showLabels && (
|
||||
<span className="text-xl font-bold text-neutral-900 dark:text-neutral-100">{t('admin.title')}</span>
|
||||
)}
|
||||
{/* When collapsed on desktop the title is hidden; on mobile we
|
||||
always show it because the rail-narrow style only applies at lg+ */}
|
||||
{collapsed && (
|
||||
<span className="text-xl font-bold text-neutral-900 dark:text-neutral-100 lg:hidden">{t('admin.title')}</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="lg:hidden text-neutral-400 hover:text-neutral-600"
|
||||
aria-label="Close sidebar"
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 px-4 py-4 space-y-1 overflow-y-auto min-h-0">
|
||||
<nav className={`flex-1 py-4 space-y-1 overflow-y-auto overflow-x-hidden min-h-0 ${
|
||||
collapsed ? 'px-4 lg:px-2' : 'px-4'
|
||||
}`}>
|
||||
{filteredNavigation.map((item) => {
|
||||
const isActive = location.pathname === item.href ||
|
||||
const isActive = location.pathname === item.href ||
|
||||
(item.href !== '/admin/dashboard' && location.pathname.startsWith(item.href));
|
||||
|
||||
const label = t(item.nameKey);
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
key={item.nameKey}
|
||||
to={item.href}
|
||||
onClick={() => onClose()}
|
||||
className={`flex items-center px-3 py-2 text-sm font-medium rounded-lg transition-colors ${
|
||||
title={collapsed ? label : undefined}
|
||||
className={`flex items-center py-2 text-sm font-medium rounded-lg transition-colors ${
|
||||
collapsed ? 'px-3 lg:px-0 lg:justify-center' : 'px-3'
|
||||
} ${
|
||||
isActive
|
||||
? 'bg-accent-dark text-white'
|
||||
: 'text-neutral-700 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800 hover:text-neutral-900 dark:hover:text-neutral-100'
|
||||
@@ -143,18 +239,46 @@ export const AdminSidebar: React.FC<AdminSidebarProps> = ({ isOpen, onClose }) =
|
||||
.tile-selected pattern used in the customizer. The accent
|
||||
-dark token defaults to the legacy primary green so users
|
||||
who haven't set CI colours yet see no migration regression. */}
|
||||
<item.icon className={`w-5 h-5 mr-3 ${
|
||||
<item.icon className={`w-5 h-5 flex-shrink-0 ${
|
||||
collapsed ? 'mr-3 lg:mr-0' : 'mr-3'
|
||||
} ${
|
||||
isActive ? 'text-white' : 'text-neutral-400'
|
||||
}`} />
|
||||
{t(item.nameKey)}
|
||||
<span className={collapsed ? 'lg:hidden' : ''}>{label}</span>
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Bottom section - sticky to bottom (only for users with settings.view permission) */}
|
||||
{/* Desktop collapse / expand toggle.
|
||||
Lives directly above the version + storage widgets — sits
|
||||
in admins' muscle-memory zone for chrome controls and
|
||||
stays visible even when the sidebar is collapsed so the
|
||||
rail can always be re-expanded. Hidden on mobile (the X
|
||||
in the brand row already closes the sheet there). */}
|
||||
{onToggleCollapse && (
|
||||
<div className={`hidden lg:flex flex-shrink-0 border-t border-neutral-200 dark:border-neutral-700 py-2 ${
|
||||
collapsed ? 'justify-center px-2' : 'justify-end px-4'
|
||||
}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleCollapse}
|
||||
className="inline-flex items-center justify-center w-9 h-9 rounded-md text-neutral-500 hover:text-neutral-900 dark:hover:text-neutral-100 hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors"
|
||||
aria-label={collapsed ? t('admin.expandSidebar', 'Expand sidebar') : t('admin.collapseSidebar', 'Collapse sidebar')}
|
||||
title={collapsed ? t('admin.expandSidebar', 'Expand sidebar') : t('admin.collapseSidebar', 'Collapse sidebar')}
|
||||
>
|
||||
{collapsed
|
||||
? <PanelLeftOpen className="w-5 h-5" />
|
||||
: <PanelLeftClose className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bottom section - sticky to bottom (only for users with settings.view permission).
|
||||
Hidden on desktop when collapsed since these widgets don't fit in the icon rail;
|
||||
mobile keeps them visible because mobile width is always w-64. */}
|
||||
{hasPermission('settings.view') && (
|
||||
<div className="flex-shrink-0">
|
||||
<div className={`flex-shrink-0 ${collapsed ? 'lg:hidden' : ''}`}>
|
||||
{/* Version Info */}
|
||||
<VersionInfo />
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
import React from 'react';
|
||||
import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Briefcase, UserCog } from 'lucide-react';
|
||||
import { Briefcase, UserCog, FileText, Receipt, Wrench, Calculator, Clock, ScrollText, Calendar } from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { useFeatureFlags, type FeatureKey } from '../../contexts/FeatureFlagsContext';
|
||||
|
||||
@@ -46,12 +46,57 @@ export const ClientsLayout: React.FC = () => {
|
||||
icon: UserCog,
|
||||
featureFlag: 'customerPortal',
|
||||
},
|
||||
// Add future sub-features here as they ship:
|
||||
// { key: 'calendar', to: '/admin/clients/calendar', ... featureFlag: 'calendar' }
|
||||
// { key: 'quotes', to: '/admin/clients/quotes', ... featureFlag: 'quotes' }
|
||||
// { key: 'bills', to: '/admin/clients/bills', ... featureFlag: 'bills' }
|
||||
// etc. The empty-state below disappears automatically once any of
|
||||
// these is enabled.
|
||||
{
|
||||
key: 'calendar',
|
||||
to: '/admin/clients/calendar',
|
||||
label: t('clients.subnav.calendar', 'Calendar'),
|
||||
icon: Calendar,
|
||||
featureFlag: 'calendar',
|
||||
},
|
||||
{
|
||||
key: 'quotes',
|
||||
to: '/admin/clients/quotes',
|
||||
label: t('clients.subnav.quotes', 'Quotes'),
|
||||
icon: FileText,
|
||||
featureFlag: 'quotes',
|
||||
},
|
||||
{
|
||||
key: 'contracts',
|
||||
to: '/admin/clients/contracts',
|
||||
label: t('clients.subnav.contracts', 'Contracts'),
|
||||
icon: ScrollText,
|
||||
featureFlag: 'contracts',
|
||||
},
|
||||
{
|
||||
key: 'hours',
|
||||
to: '/admin/clients/hours',
|
||||
label: t('clients.subnav.hours', 'Hours'),
|
||||
icon: Clock,
|
||||
featureFlag: 'hoursLogging',
|
||||
},
|
||||
{
|
||||
key: 'bills',
|
||||
to: '/admin/clients/bills',
|
||||
label: t('clients.subnav.bills', 'Invoices'),
|
||||
icon: Receipt,
|
||||
featureFlag: 'bills',
|
||||
},
|
||||
{
|
||||
key: 'tax-report',
|
||||
to: '/admin/clients/tax-report',
|
||||
label: t('clients.subnav.taxReport', 'Tax'),
|
||||
icon: Calculator,
|
||||
featureFlag: 'taxReport',
|
||||
},
|
||||
// Future sub-features:
|
||||
// { key: 'messaging', ... featureFlag: 'messaging' }
|
||||
{
|
||||
key: 'development',
|
||||
to: '/admin/clients/development',
|
||||
label: t('clients.subnav.development', 'Development'),
|
||||
icon: Wrench,
|
||||
featureFlag: 'crmDevelopment',
|
||||
},
|
||||
];
|
||||
|
||||
const enabledItems = navItems.filter((item) => flags[item.featureFlag]);
|
||||
@@ -64,22 +109,22 @@ export const ClientsLayout: React.FC = () => {
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">
|
||||
{t('clients.title', 'Clients')}
|
||||
{t('clients.title', 'CRM')}
|
||||
</h1>
|
||||
<p className="text-neutral-600 dark:text-neutral-400 mt-1">
|
||||
{t('clients.subtitle', 'Customer accounts, scheduling, quotes and billing for recurring clients.')}
|
||||
{t('clients.subtitle', 'Customer accounts, scheduling, quotes and billing — everything for recurring clients in one place.')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-dashed border-neutral-300 dark:border-neutral-700 bg-neutral-50 dark:bg-neutral-900 p-8 text-center">
|
||||
<Briefcase className="w-10 h-10 mx-auto mb-3 text-neutral-400" />
|
||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-1">
|
||||
{t('clients.empty.title', 'No client features enabled')}
|
||||
{t('clients.empty.title', 'No CRM features enabled')}
|
||||
</h2>
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||
{t(
|
||||
'clients.empty.body',
|
||||
'Enable Accounts (or another Clients sub-feature) under Settings → Features to get started.',
|
||||
'Enable Accounts (or another CRM sub-feature) under Settings → Features to get started.',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -91,10 +136,10 @@ export const ClientsLayout: React.FC = () => {
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">
|
||||
{t('clients.title', 'Clients')}
|
||||
{t('clients.title', 'CRM')}
|
||||
</h1>
|
||||
<p className="text-neutral-600 dark:text-neutral-400 mt-1">
|
||||
{t('clients.subtitle', 'Customer accounts, scheduling, quotes and billing for recurring clients.')}
|
||||
{t('clients.subtitle', 'Customer accounts, scheduling, quotes and billing — everything for recurring clients in one place.')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -103,7 +148,7 @@ export const ClientsLayout: React.FC = () => {
|
||||
in one tap on touch devices, no horizontal scroll. */}
|
||||
<div className="lg:hidden">
|
||||
<label htmlFor="clients-section" className="sr-only">
|
||||
{t('clients.navAriaLabel', 'Clients navigation')}
|
||||
{t('clients.navAriaLabel', 'CRM navigation')}
|
||||
</label>
|
||||
<select
|
||||
id="clients-section"
|
||||
@@ -120,7 +165,7 @@ export const ClientsLayout: React.FC = () => {
|
||||
{/* Desktop: sticky left rail */}
|
||||
<aside className="hidden lg:block">
|
||||
<nav
|
||||
aria-label={t('clients.navAriaLabel', 'Clients navigation')}
|
||||
aria-label={t('clients.navAriaLabel', 'CRM navigation')}
|
||||
className="sticky top-6 space-y-1"
|
||||
>
|
||||
{enabledItems.map((item) => {
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* CrmOverviewSection — headline metrics embedded into the main
|
||||
* AdminDashboard for admins who use the CRM features.
|
||||
*
|
||||
* Feature-flag gating (three layers):
|
||||
* - `clients` parent flag OFF → renders nothing
|
||||
* - only `quotes` enabled → only the quote cards render
|
||||
* - only `bills` enabled → only the invoice + revenue
|
||||
* + outstanding cards render
|
||||
* - both enabled → full section
|
||||
*
|
||||
* Numbers come from /api/admin/dashboard/crm-stats. Each card deep-
|
||||
* links into the matching filtered list so the admin can drill in
|
||||
* with one click.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
FileText, Send, CheckCircle2, XCircle, Clock,
|
||||
Receipt, AlertTriangle, TrendingUp, Wallet,
|
||||
} from 'lucide-react';
|
||||
import { Card } from '../common';
|
||||
import { fetchCrmOverview, type CrmOverviewStats } from '../../services/bills.service';
|
||||
import { useFeatureFlags } from '../../contexts/FeatureFlagsContext';
|
||||
import { usePublicSettings } from '../../hooks/usePublicSettings';
|
||||
|
||||
import { formatMoneyMinor } from '../../utils/money';
|
||||
// Local alias preserved so call-sites in this file keep their
|
||||
// minor-units semantics. The unified helper handles the /100 conversion.
|
||||
const formatMoney = formatMoneyMinor;
|
||||
|
||||
export const CrmOverviewSection: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { flags } = useFeatureFlags();
|
||||
const { data: publicSettings } = usePublicSettings();
|
||||
|
||||
// Outer gate: hide the entire CRM block when the parent feature
|
||||
// flag is off. The query is also skipped so we don't hit the
|
||||
// endpoint at all on non-CRM installs.
|
||||
const clientsOn = !!flags.clients;
|
||||
const quotesOn = clientsOn && !!flags.quotes;
|
||||
const billsOn = clientsOn && !!flags.bills;
|
||||
const anyCrm = quotesOn || billsOn;
|
||||
|
||||
// Per-tile visibility (admin pref in Settings → CRM behaviour). All
|
||||
// default true; only explicit false hides the matching tile. We
|
||||
// resolve via `!== false` so the very first render — before
|
||||
// publicSettings finishes loading — shows everything, then settles.
|
||||
const showRevenue = publicSettings?.crm_overview_show_revenue !== false;
|
||||
const showOutstanding = publicSettings?.crm_overview_show_outstanding !== false;
|
||||
const showQuotes = publicSettings?.crm_overview_show_quotes !== false;
|
||||
const showInvoices = publicSettings?.crm_overview_show_invoices !== false;
|
||||
// Compute which sub-sections actually render so we can skip the
|
||||
// outer block entirely when the admin hid everything.
|
||||
const billsRevenueRow = billsOn && (showRevenue || showOutstanding);
|
||||
const quotesBlock = quotesOn && showQuotes;
|
||||
const invoicesBlock = billsOn && showInvoices;
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ['crm-overview'],
|
||||
queryFn: () => fetchCrmOverview(),
|
||||
enabled: anyCrm,
|
||||
// Dashboard tile aggregates — admin opens this on every dashboard
|
||||
// load; refetching the full aggregate on every mount adds DB load
|
||||
// without observable benefit. 60s window covers the typical
|
||||
// "click into a contract / click back" pattern.
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
if (!anyCrm) return null;
|
||||
// Admin hid every CRM tile — render nothing, including the heading.
|
||||
if (!billsRevenueRow && !quotesBlock && !invoicesBlock) return null;
|
||||
if (isLoading) return null;
|
||||
if (isError || !data) {
|
||||
// Surface a tiny inline notice when the section is enabled by
|
||||
// flags but the API failed — silent renders make this hard to
|
||||
// debug (the user reported "everything turned on but nothing
|
||||
// shows" which traced back to a permission check on the
|
||||
// backend). Keep it small so it doesn't disrupt the page.
|
||||
return (
|
||||
<section className="mt-8">
|
||||
<h2 className="text-xl font-bold text-theme mb-2">
|
||||
{t('crmOverview.title', 'CRM overview')}
|
||||
</h2>
|
||||
<p className="text-sm text-red-600">
|
||||
{t('crmOverview.loadError',
|
||||
'Could not load CRM stats. Check that you have bills.view or quotes.view permission and that the backend is on the latest build.')}
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const d: CrmOverviewStats = data;
|
||||
const cur = d.currency || 'CHF';
|
||||
|
||||
return (
|
||||
<section className="mt-8 space-y-5">
|
||||
<h2 className="text-xl font-bold text-theme">
|
||||
{t('crmOverview.title', 'CRM overview')}
|
||||
</h2>
|
||||
|
||||
{/* Revenue + outstanding (bills feature only). Revenue trio and
|
||||
outstanding tile are gated independently — admins who only
|
||||
want the outstanding figure (or vice versa) can hide either
|
||||
via Settings → CRM behaviour. */}
|
||||
{billsRevenueRow && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
{showRevenue && (
|
||||
<>
|
||||
<StatCard
|
||||
icon={<TrendingUp className="w-5 h-5" />}
|
||||
label={t('crmOverview.revenue.month', 'Revenue · last 30 days')}
|
||||
value={formatMoney(d.revenue.monthMinor, cur)}
|
||||
/>
|
||||
<StatCard
|
||||
icon={<TrendingUp className="w-5 h-5" />}
|
||||
label={t('crmOverview.revenue.quarter', 'Revenue · last 90 days')}
|
||||
value={formatMoney(d.revenue.quarterMinor, cur)}
|
||||
/>
|
||||
<StatCard
|
||||
icon={<TrendingUp className="w-5 h-5" />}
|
||||
label={t('crmOverview.revenue.year', 'Revenue · last 365 days')}
|
||||
value={formatMoney(d.revenue.yearMinor, cur)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{showOutstanding && (
|
||||
<StatCard
|
||||
icon={<Wallet className="w-5 h-5 text-red-600" />}
|
||||
label={t('crmOverview.outstanding', 'Outstanding payments')}
|
||||
value={formatMoney(d.outstanding.totalMinor, cur)}
|
||||
sub={t('crmOverview.outstandingSub', '{{count}} invoice(s) unpaid', {
|
||||
count: d.outstanding.invoiceCount,
|
||||
})}
|
||||
to="/admin/clients/bills?unpaidOnly=true"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quotes pipeline (quotes feature only) */}
|
||||
{quotesBlock && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-base font-semibold flex items-center gap-2">
|
||||
<FileText className="w-5 h-5" />
|
||||
{t('crmOverview.quotes.title', 'Quotes')}
|
||||
</h3>
|
||||
<Link to="/admin/clients/quotes" className="text-sm text-primary-600 dark:text-primary-400 hover:underline">
|
||||
{t('crmOverview.viewAll', 'View all')} →
|
||||
</Link>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">
|
||||
<StatCard
|
||||
icon={<Clock className="w-5 h-5 text-amber-600" />}
|
||||
label={t('quotes.status.draft', 'Drafts')}
|
||||
value={d.quotes.draft}
|
||||
to="/admin/clients/quotes?status=draft"
|
||||
/>
|
||||
<StatCard
|
||||
icon={<Send className="w-5 h-5 text-blue-600" />}
|
||||
label={t('quotes.status.sent', 'Sent / open')}
|
||||
value={d.quotes.sent}
|
||||
to="/admin/clients/quotes?status=sent"
|
||||
/>
|
||||
<StatCard
|
||||
icon={<CheckCircle2 className="w-5 h-5 text-green-600" />}
|
||||
label={t('quotes.status.accepted', 'Accepted')}
|
||||
value={d.quotes.accepted}
|
||||
to="/admin/clients/quotes?status=accepted"
|
||||
/>
|
||||
<StatCard
|
||||
icon={<XCircle className="w-5 h-5 text-red-600" />}
|
||||
label={t('quotes.status.declined', 'Declined')}
|
||||
value={d.quotes.declined}
|
||||
to="/admin/clients/quotes?status=declined"
|
||||
/>
|
||||
<StatCard
|
||||
icon={<Clock className="w-5 h-5 text-neutral-500" />}
|
||||
label={t('quotes.status.expired', 'Expired')}
|
||||
value={d.quotes.expired}
|
||||
to="/admin/clients/quotes?status=expired"
|
||||
/>
|
||||
<StatCard
|
||||
icon={<CheckCircle2 className="w-5 h-5 text-emerald-700" />}
|
||||
label={t('quotes.status.converted', 'Converted')}
|
||||
value={d.quotes.converted}
|
||||
to="/admin/clients/quotes?status=converted"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Invoices pipeline (bills feature only) */}
|
||||
{invoicesBlock && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-base font-semibold flex items-center gap-2">
|
||||
<Receipt className="w-5 h-5" />
|
||||
{t('crmOverview.invoices.title', 'Invoices')}
|
||||
</h3>
|
||||
<Link to="/admin/clients/bills" className="text-sm text-primary-600 dark:text-primary-400 hover:underline">
|
||||
{t('crmOverview.viewAll', 'View all')} →
|
||||
</Link>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-3">
|
||||
<StatCard
|
||||
icon={<Clock className="w-5 h-5 text-amber-600" />}
|
||||
label={t('bills.status.scheduled', 'Scheduled')}
|
||||
value={d.invoices.scheduled}
|
||||
to="/admin/clients/bills?status=scheduled"
|
||||
/>
|
||||
<StatCard
|
||||
icon={<Send className="w-5 h-5 text-blue-600" />}
|
||||
label={t('bills.status.sent', 'Sent / open')}
|
||||
value={d.invoices.sent}
|
||||
to="/admin/clients/bills?status=sent"
|
||||
/>
|
||||
<StatCard
|
||||
icon={<CheckCircle2 className="w-5 h-5 text-green-600" />}
|
||||
label={t('bills.status.paid', 'Paid')}
|
||||
value={d.invoices.paid}
|
||||
to="/admin/clients/bills?status=paid"
|
||||
/>
|
||||
<StatCard
|
||||
icon={<AlertTriangle className="w-5 h-5 text-red-600" />}
|
||||
label={t('bills.status.overdue', 'Overdue')}
|
||||
value={d.invoices.overdue}
|
||||
to="/admin/clients/bills?status=overdue"
|
||||
/>
|
||||
<StatCard
|
||||
icon={<XCircle className="w-5 h-5 text-neutral-500" />}
|
||||
label={t('bills.status.cancelled', 'Cancelled')}
|
||||
value={d.invoices.cancelled}
|
||||
to="/admin/clients/bills?status=cancelled"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
interface StatCardProps {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value: string | number;
|
||||
sub?: string;
|
||||
to?: string;
|
||||
}
|
||||
const StatCard: React.FC<StatCardProps> = ({ icon, label, value, sub, to }) => {
|
||||
const inner = (
|
||||
<Card padding="md" className="h-full">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="shrink-0 mt-0.5">{icon}</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs uppercase tracking-wider text-muted-theme">{label}</div>
|
||||
<div className="text-2xl font-bold tabular-nums mt-1">{value}</div>
|
||||
{sub && <div className="text-xs text-muted-theme mt-1">{sub}</div>}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
if (to) {
|
||||
return <Link to={to} className="block hover:opacity-90 transition-opacity">{inner}</Link>;
|
||||
}
|
||||
return inner;
|
||||
};
|
||||
|
||||
export default CrmOverviewSection;
|
||||
@@ -5,9 +5,11 @@ import { toast } from 'react-toastify';
|
||||
import { Save, RotateCcw, Code, AlertTriangle, Check } from 'lucide-react';
|
||||
import { Button, Card, Loading } from '../common';
|
||||
import { cssTemplatesService, CssTemplate } from '../../services/cssTemplates.service';
|
||||
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
||||
|
||||
export const CssTemplateEditor: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { formatDateTime: fmtDateTime } = useLocalizedDate();
|
||||
const queryClient = useQueryClient();
|
||||
const [activeSlot, setActiveSlot] = useState(1);
|
||||
const [localTemplates, setLocalTemplates] = useState<CssTemplate[]>([]);
|
||||
@@ -228,7 +230,7 @@ export const CssTemplateEditor: React.FC = () => {
|
||||
{/* Last Updated */}
|
||||
{activeTemplate.updated_at && (
|
||||
<p className="text-xs text-neutral-400 dark:text-neutral-500 text-right">
|
||||
{t('cssTemplates.lastUpdated', 'Last updated')}: {new Date(activeTemplate.updated_at).toLocaleString()}
|
||||
{t('cssTemplates.lastUpdated', 'Last updated')}: {fmtDateTime(activeTemplate.updated_at)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* Customer CRM panels — quotes + invoices history shown on the customer
|
||||
* detail page. Each panel:
|
||||
* - is gated by its global feature flag (`quotes` / `bills`); when the
|
||||
* flag is off the panel doesn't render at all (no empty space)
|
||||
* - shows the 10 most recent rows for that customer, with status
|
||||
* badge, total and a click-through to the full document
|
||||
* - exposes a "New …" button + a "Show all" link to the global list
|
||||
* pre-filtered by this customer
|
||||
*
|
||||
* Lives as a separate component so CustomerDetailPage doesn't need to
|
||||
* know about CRM types; the panels handle their own data fetching.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { FileText, Plus, Receipt, ScrollText } from 'lucide-react';
|
||||
import { Card, Button, Loading } from '../common';
|
||||
import { useFeatureFlags } from '../../contexts/FeatureFlagsContext';
|
||||
import { quotesService } from '../../services/quotes.service';
|
||||
import { billsService } from '../../services/bills.service';
|
||||
import { contractsService } from '../../services/contracts.service';
|
||||
import { formatMoney } from './LineItemsTable';
|
||||
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
||||
|
||||
interface Props {
|
||||
customerAccountId: number;
|
||||
}
|
||||
|
||||
export const CustomerCrmPanels: React.FC<Props> = ({ customerAccountId }) => {
|
||||
const { flags } = useFeatureFlags();
|
||||
|
||||
return (
|
||||
<>
|
||||
{flags.quotes && <QuotesPanel customerAccountId={customerAccountId} />}
|
||||
{flags.contracts && <ContractsPanel customerAccountId={customerAccountId} />}
|
||||
{flags.bills && <InvoicesPanel customerAccountId={customerAccountId} />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const QuotesPanel: React.FC<Props> = ({ customerAccountId }) => {
|
||||
const { t } = useTranslation();
|
||||
const { format: fmtDate } = useLocalizedDate();
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['customer-quotes', customerAccountId],
|
||||
queryFn: () => quotesService.list({ customerAccountId, page: 1, pageSize: 10, sort: 'newest' }),
|
||||
// Customer detail page mounts these three panels together. Without
|
||||
// staleTime they all refetch on every visit + every queryClient
|
||||
// touch elsewhere. 30s lets a quick tab-out/tab-in not re-hit the
|
||||
// API; admin mutations invalidate the cache explicitly when they
|
||||
// need fresh data.
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
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">
|
||||
<FileText className="w-5 h-5" /> {t('customers.detail.quotesSection', 'Quotes')}
|
||||
</h2>
|
||||
<div className="flex gap-2">
|
||||
<Link to={`/admin/clients/quotes?customerAccountId=${customerAccountId}`}>
|
||||
<Button variant="outline" size="sm">{t('common.showAll', 'Show all')}</Button>
|
||||
</Link>
|
||||
{/* "New quote" pre-fills via state on QuoteEditorPage when a
|
||||
customerAccountId search-param is present (cheap follow-up
|
||||
if you want it). For now the editor's customer picker
|
||||
starts empty. */}
|
||||
{/* Pre-fill this customer on the editor via search-param
|
||||
so the admin doesn't have to retype it. The editor picks
|
||||
it up on mount. */}
|
||||
<Link to={`/admin/clients/quotes/new?customerAccountId=${customerAccountId}`}>
|
||||
<Button size="sm"><Plus className="w-4 h-4 mr-1" />{t('quotes.new', 'New quote')}</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? <Loading /> : !data || data.quotes.length === 0 ? (
|
||||
<p className="text-sm text-muted-theme">
|
||||
{t('customers.detail.noQuotes', 'No quotes for this customer yet.')}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y" style={{ borderColor: 'var(--color-surface-border)' }}>
|
||||
{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">
|
||||
{q.quoteNumber}
|
||||
</Link>
|
||||
<span className="text-xs text-muted-theme 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 ${
|
||||
q.status === 'accepted' || q.status === 'converted' ? 'bg-green-100 text-green-800'
|
||||
: q.status === 'declined' ? 'bg-red-100 text-red-800'
|
||||
: q.status === 'sent' ? 'bg-blue-100 text-blue-800'
|
||||
: 'bg-neutral-100 text-neutral-700'
|
||||
}`}>{t(`quotes.status.${q.status}`, q.status)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const ContractsPanel: React.FC<Props> = ({ customerAccountId }) => {
|
||||
const { t } = useTranslation();
|
||||
const { format: fmtDate } = useLocalizedDate();
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['customer-contracts', customerAccountId],
|
||||
queryFn: () => contractsService.list({ customerAccountId, page: 1, pageSize: 10, sort: 'newest' }),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
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">
|
||||
<ScrollText className="w-5 h-5" /> {t('customers.detail.contractsSection', 'Contracts')}
|
||||
</h2>
|
||||
<div className="flex gap-2">
|
||||
<Link to={`/admin/clients/contracts?customerAccountId=${customerAccountId}`}>
|
||||
<Button variant="outline" size="sm">{t('common.showAll', 'Show all')}</Button>
|
||||
</Link>
|
||||
<Link to={`/admin/clients/contracts/new?customerAccountId=${customerAccountId}`}>
|
||||
<Button size="sm"><Plus className="w-4 h-4 mr-1" />{t('contracts.list.new', 'New contract')}</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? <Loading /> : !data || data.contracts.length === 0 ? (
|
||||
<p className="text-sm text-muted-theme">
|
||||
{t('customers.detail.noContracts', 'No contracts for this customer yet.')}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y" style={{ borderColor: 'var(--color-surface-border)' }}>
|
||||
{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">
|
||||
{c.contractNumber}
|
||||
</Link>
|
||||
<span className="text-xs text-muted-theme 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'
|
||||
: c.status === 'signed_by_customer' || c.status === 'signed_by_admin' ? 'bg-blue-100 text-blue-800'
|
||||
: c.status === 'sent' ? 'bg-amber-100 text-amber-800'
|
||||
: c.status === 'cancelled' ? 'bg-neutral-200 text-neutral-600'
|
||||
: 'bg-neutral-100 text-neutral-700'
|
||||
}`}>{t(`contracts.status.${c.status}`, c.status)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const InvoicesPanel: React.FC<Props> = ({ customerAccountId }) => {
|
||||
const { t } = useTranslation();
|
||||
const { format: fmtDate } = useLocalizedDate();
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['customer-invoices', customerAccountId],
|
||||
queryFn: () => billsService.list({ customerAccountId, page: 1, pageSize: 10, sort: 'newest' }),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
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">
|
||||
<Receipt className="w-5 h-5" /> {t('customers.detail.billsSection', 'Invoices')}
|
||||
</h2>
|
||||
<div className="flex gap-2">
|
||||
<Link to={`/admin/clients/bills?customerAccountId=${customerAccountId}`}>
|
||||
<Button variant="outline" size="sm">{t('common.showAll', 'Show all')}</Button>
|
||||
</Link>
|
||||
{/* Same prefill trick as quotes — see comment in QuotesPanel. */}
|
||||
<Link to={`/admin/clients/bills/new?customerAccountId=${customerAccountId}`}>
|
||||
<Button size="sm"><Plus className="w-4 h-4 mr-1" />{t('bills.new', 'New invoice')}</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? <Loading /> : !data || data.invoices.length === 0 ? (
|
||||
<p className="text-sm text-muted-theme">
|
||||
{t('customers.detail.noBills', 'No invoices for this customer yet.')}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y" style={{ borderColor: 'var(--color-surface-border)' }}>
|
||||
{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">
|
||||
{inv.invoiceNumber}
|
||||
</Link>
|
||||
<span className="text-xs text-muted-theme ml-2">
|
||||
{fmtDate(inv.dueDate)}
|
||||
{inv.installmentTotal > 1 ? ` · ${inv.installmentIndex + 1}/${inv.installmentTotal}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-sm tabular-nums">{formatMoney(Number(inv.totalAmountMinor) / 100, inv.currency)}</span>
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium ${
|
||||
inv.status === 'paid' ? 'bg-green-100 text-green-800'
|
||||
: inv.status === 'overdue' ? 'bg-red-100 text-red-800'
|
||||
: inv.status === 'sent' ? 'bg-blue-100 text-blue-800'
|
||||
: inv.status === 'cancelled' ? 'bg-neutral-200 text-neutral-600'
|
||||
: inv.status === 'skipped' ? 'bg-neutral-100 text-neutral-500 italic'
|
||||
: 'bg-amber-100 text-amber-800'
|
||||
}`}>{t(`bills.status.${inv.status}`, inv.status)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* CustomerPicker — shared search-or-create surface for the three CRM
|
||||
* editors (Quote, Bill, Contract).
|
||||
*
|
||||
* **Why this exists**
|
||||
*
|
||||
* The audit flagged that QuoteEditorPage, BillEditorPage, and
|
||||
* ContractEditorPage each carried near-identical ~80-line customer
|
||||
* picker blocks: a "currently selected" row when an id is present,
|
||||
* a search-debounced lookup with passive-badge chips, and an
|
||||
* "InlineCustomerCreate" expansion when the admin clicks "+ Create
|
||||
* new customer". The three copies had drifted: passive badge text
|
||||
* positioning differed, the contract variant used a bare `<input>`
|
||||
* instead of the shared `<Input>` component, and the contract change
|
||||
* link used `text-accent-dark hover:underline` instead of the
|
||||
* Button-variant "outline" style the other two used.
|
||||
*
|
||||
* Behavior unified here matches the Quote and Bill variants (which
|
||||
* were already in sync with each other); the contract variant's
|
||||
* styling differences are folded in.
|
||||
*
|
||||
* **API**
|
||||
*
|
||||
* <CustomerPicker
|
||||
* value={customerAccountId} // number | null
|
||||
* label={customerLabel} // pre-formatted display label
|
||||
* isPassive={customerIsPassive} // boolean
|
||||
* onSelect={(c) => { ... }} // CustomerSummary from search
|
||||
* onCreate={(c) => { ... }} // CustomerAccountDetail from inline create
|
||||
* onClear={() => { ... }} // user clicked "Change"
|
||||
* readOnly={false} // contract editor uses true on edit
|
||||
* />
|
||||
*
|
||||
* The component owns the `customerSearch` state + the debounced
|
||||
* useQuery against customerAdminService.search and the "+ Create new
|
||||
* customer" expansion toggle. Parents own the canonical
|
||||
* `customerAccountId / label / isPassive` triple because each editor
|
||||
* stores them differently (Quote nests them inside a form object,
|
||||
* Bill + Contract use separate useStates). Keeping the triple owned
|
||||
* by the parent avoids a forced shape migration.
|
||||
*
|
||||
* **Selection vs creation callbacks**
|
||||
*
|
||||
* `onSelect` receives the CustomerSummary shape from the search
|
||||
* endpoint (id, email, displayName, companyName, firstName, lastName,
|
||||
* isPassive). `onCreate` receives the full CustomerAccountDetail
|
||||
* because some editors want to inherit additional fields from a
|
||||
* freshly-created customer (e.g. Quote inherits the new customer's
|
||||
* preferredLanguage so the doc renders in their locale by default).
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Button, Input } from '../common';
|
||||
import { InlineCustomerCreate } from './InlineCustomerCreate';
|
||||
import {
|
||||
customerAdminService,
|
||||
type CustomerAccountDetail,
|
||||
type CustomerAccountSummary,
|
||||
} from '../../services/customerAdmin.service';
|
||||
|
||||
// Alias for clarity at the call-site: search returns the Summary shape.
|
||||
export type CustomerSummary = CustomerAccountSummary;
|
||||
|
||||
export interface CustomerPickerProps {
|
||||
value: number | null;
|
||||
label: string;
|
||||
isPassive: boolean;
|
||||
onSelect: (customer: CustomerSummary) => void;
|
||||
onCreate: (customer: CustomerAccountDetail) => void;
|
||||
onClear: () => void;
|
||||
/**
|
||||
* Read-only mode: render only the selected-row chip, hide search +
|
||||
* create + change. Used by the contract editor in edit mode where
|
||||
* the customer is locked to whatever the contract was created with.
|
||||
*/
|
||||
readOnly?: boolean;
|
||||
/**
|
||||
* Placeholder override for the search box. Defaults to the i18n
|
||||
* `crm.customerPicker.search` key with an EN fallback. Specific
|
||||
* editors can pass a doc-type-flavoured label.
|
||||
*/
|
||||
searchPlaceholder?: string;
|
||||
/**
|
||||
* F.6 — surface a feature-gate badge so the admin sees up front that
|
||||
* selecting a particular customer won't work for this surface (e.g.
|
||||
* the calendar's hour-entry drag-create modal would 409 the backend
|
||||
* on a customer whose `feature_hours_logging` is OFF).
|
||||
* Currently only 'hoursLogging' is supported; pass undefined to
|
||||
* skip the badge entirely (default for quote / bill / contract
|
||||
* editors which don't care about hour-logging eligibility).
|
||||
*/
|
||||
requireFeature?: 'hoursLogging';
|
||||
}
|
||||
|
||||
export const CustomerPicker: React.FC<CustomerPickerProps> = ({
|
||||
value,
|
||||
label,
|
||||
isPassive,
|
||||
onSelect,
|
||||
onCreate,
|
||||
onClear,
|
||||
readOnly = false,
|
||||
searchPlaceholder,
|
||||
requireFeature,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [search, setSearch] = useState('');
|
||||
const [creating, setCreating] = useState(false);
|
||||
// Debounce the search term before it hits the API. The previous
|
||||
// shape fired one search request per keystroke; on a passive-
|
||||
// customer list of 500+ rows, that's hundreds of /api/admin/customers
|
||||
// calls during a single look-up. 250ms is below the perceptual
|
||||
// threshold for typing.
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||
useEffect(() => {
|
||||
const handle = window.setTimeout(() => setDebouncedSearch(search), 250);
|
||||
return () => window.clearTimeout(handle);
|
||||
}, [search]);
|
||||
|
||||
const { data: options = [] } = useQuery({
|
||||
queryKey: ['crm-customer-picker', debouncedSearch],
|
||||
queryFn: () => customerAdminService.search(debouncedSearch),
|
||||
enabled: !readOnly && !value && !creating && debouncedSearch.trim().length >= 2,
|
||||
});
|
||||
|
||||
if (value) {
|
||||
return (
|
||||
<div className="flex items-center justify-between bg-neutral-50 dark:bg-neutral-800 rounded-md px-3 py-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm">{label || `#${value}`}</span>
|
||||
{isPassive && (
|
||||
<span className="inline-flex items-center gap-1 text-[11px] px-1.5 py-0.5 rounded bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300">
|
||||
{t('customers.passive.badge', 'Passive — admin only')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{!readOnly && (
|
||||
<Button variant="outline" size="sm" onClick={onClear}>
|
||||
{t('common.change', 'Change')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (creating) {
|
||||
return (
|
||||
<InlineCustomerCreate
|
||||
onCancel={() => setCreating(false)}
|
||||
onCreated={(c) => {
|
||||
onCreate(c);
|
||||
setCreating(false);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Input
|
||||
placeholder={searchPlaceholder
|
||||
|| (t('crm.customerPicker.search', 'Search customer by email or company…') as string)}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
{options.length > 0 && (
|
||||
<ul className="mt-2 rounded-md border border-neutral-200 dark:border-neutral-700 divide-y divide-neutral-200 dark:divide-neutral-700">
|
||||
{options.map((c) => {
|
||||
// F.6 — gate badge for the calendar's hour-entry create
|
||||
// modal. Selecting a customer with feature_hours_logging
|
||||
// OFF would 409 the backend; warn up front. We still allow
|
||||
// the click so the admin can open the customer's detail
|
||||
// page to flip the flag from a separate tab.
|
||||
const hourLoggingOff =
|
||||
requireFeature === 'hoursLogging' && c.featureHoursLogging === false;
|
||||
return (
|
||||
<li key={c.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(c)}
|
||||
className="w-full text-left px-3 py-2 hover:bg-neutral-50 dark:hover:bg-neutral-800 text-sm"
|
||||
>
|
||||
<span className="font-medium">
|
||||
{c.companyName || c.displayName || c.email}
|
||||
</span>
|
||||
<span className="text-neutral-500 ml-2">{c.email}</span>
|
||||
{c.isPassive && (
|
||||
<span className="ml-2 inline-flex items-center gap-1 text-[11px] px-1.5 py-0.5 rounded bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300">
|
||||
{t('customers.passive.badge', 'Passive — admin only')}
|
||||
</span>
|
||||
)}
|
||||
{hourLoggingOff && (
|
||||
<span className="ml-2 inline-flex items-center gap-1 text-[11px] px-1.5 py-0.5 rounded bg-amber-100 dark:bg-amber-900/40 text-amber-800 dark:text-amber-300">
|
||||
{t('customers.hoursLoggingDisabled.badge', 'Hour logging disabled')}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCreating(true)}
|
||||
className="mt-3 inline-flex items-center gap-1 text-sm text-primary-600 dark:text-primary-400 hover:underline"
|
||||
>
|
||||
{t('customers.create.openLink', '+ Create new customer')}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,313 @@
|
||||
/**
|
||||
* <DocumentLineageCard> — flat list of every document sharing one
|
||||
* `deal_uuid` (migration 140), grouped by type. Renders on the three
|
||||
* detail pages (Quote / Contract / Bill) so the admin sees the
|
||||
* complete chain — quotes + contracts + invoices (incl. Storno /
|
||||
* reissue / installment siblings) — without walking individual FKs.
|
||||
*
|
||||
* Data comes from `GET /api/admin/deals/:uuid/documents` (commit #3).
|
||||
* The card is purely presentational; the parent passes the dealUuid
|
||||
* and a `currentId` so the row representing "the document you're
|
||||
* looking at" can be highlighted instead of linked.
|
||||
*
|
||||
* When no other documents share the deal (newly-created standalone
|
||||
* doc), the card renders a compact "no related documents" line
|
||||
* instead of three empty groups — keeps the detail page from looking
|
||||
* busy on the common case.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { FileText, ScrollText, Receipt, AlertTriangle, Pencil } from 'lucide-react';
|
||||
import { Button, Card } from '../common';
|
||||
import { formatMoneyMinor } from '../../utils/money';
|
||||
import { api } from '../../config/api';
|
||||
import { EditInstallmentPlanModal } from './EditInstallmentPlanModal';
|
||||
|
||||
export interface DocumentLineageCardProps {
|
||||
dealUuid: string | null | undefined;
|
||||
/** Which document you're currently looking at. The matching row
|
||||
* renders muted (not a link) so the admin doesn't navigate to the
|
||||
* page they're already on. */
|
||||
current: { kind: 'quote' | 'contract' | 'invoice'; id: number };
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface DealItemBase {
|
||||
id: number;
|
||||
number: string;
|
||||
status: string;
|
||||
currency?: string;
|
||||
totalAmountMinor?: number;
|
||||
issueDate?: string;
|
||||
eventName?: string | null;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
interface DealQuoteItem extends DealItemBase { kind: 'quote'; validUntil?: string; }
|
||||
interface DealContractItem extends DealItemBase { kind: 'contract'; title?: string | null; validUntil?: string; }
|
||||
interface DealInvoiceItem extends DealItemBase {
|
||||
kind: 'invoice';
|
||||
invoiceKind: 'invoice' | 'storno';
|
||||
paidAmountMinor?: number;
|
||||
dueDate?: string;
|
||||
eventDate?: string | null;
|
||||
installmentIndex?: number;
|
||||
installmentTotal?: number;
|
||||
installmentLabel?: string | null;
|
||||
installmentTrigger?: string | null;
|
||||
installmentOffsetDays?: number;
|
||||
isMonthlyDraft?: boolean;
|
||||
}
|
||||
|
||||
interface DealLineageResponse {
|
||||
dealUuid: string;
|
||||
quotes: DealQuoteItem[];
|
||||
contracts: DealContractItem[];
|
||||
invoices: DealInvoiceItem[];
|
||||
}
|
||||
|
||||
const EDITABLE_PLAN_STATUSES = new Set(['scheduled', 'pending_delivery']);
|
||||
|
||||
export const DocumentLineageCard: React.FC<DocumentLineageCardProps> = ({
|
||||
dealUuid, current, className = '',
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [showEditPlan, setShowEditPlan] = useState(false);
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['deal-lineage', dealUuid],
|
||||
queryFn: async () => {
|
||||
const res = await api.get(`/admin/deals/${dealUuid}/documents`);
|
||||
return (res.data.data || res.data) as DealLineageResponse;
|
||||
},
|
||||
enabled: !!dealUuid,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
if (!dealUuid) return null;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card padding="md" className={className}>
|
||||
<p className="text-sm text-muted-theme">
|
||||
{t('dealLineage.loading', 'Loading related documents…')}
|
||||
</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
if (error) {
|
||||
return (
|
||||
<Card padding="md" className={className}>
|
||||
<p className="text-sm text-red-700 dark:text-red-300 flex items-center gap-2">
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
{t('dealLineage.error', 'Could not load related documents.')}
|
||||
</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
if (!data) return null;
|
||||
|
||||
const { quotes, contracts, invoices } = data;
|
||||
const totalCount = quotes.length + contracts.length + invoices.length;
|
||||
|
||||
// Reshape gesture is offered when this deal holds a multi-installment
|
||||
// plan AND every invoice is still pre-customer. Server re-checks on
|
||||
// save; if a sibling shipped between render and click, the 409 path
|
||||
// in the modal handles the race.
|
||||
const hasMultiInstallment = invoices.some((i) => (i.installmentTotal || 0) > 1);
|
||||
const allInvoicesEditable = invoices.length > 0
|
||||
&& invoices.every((i) => i.invoiceKind !== 'storno'
|
||||
&& EDITABLE_PLAN_STATUSES.has(i.status));
|
||||
const canEditPlan = hasMultiInstallment && allInvoicesEditable;
|
||||
// Only ONE doc total = the current one. No siblings to surface.
|
||||
if (totalCount <= 1) {
|
||||
return (
|
||||
<Card padding="md" className={className}>
|
||||
<h2 className="font-semibold mb-1 flex items-center gap-2">
|
||||
{t('dealLineage.title', 'Related documents')}
|
||||
</h2>
|
||||
<p className="text-xs text-muted-theme">
|
||||
{t('dealLineage.empty', 'No other documents share this deal yet. New invoices, contracts, or installments will show up here once created.')}
|
||||
</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card padding="md" className={className}>
|
||||
<h2 className="font-semibold mb-3">
|
||||
{t('dealLineage.title', 'Related documents')}
|
||||
</h2>
|
||||
|
||||
{quotes.length > 0 && (
|
||||
<Group
|
||||
icon={<FileText className="w-4 h-4" />}
|
||||
label={t('dealLineage.quotes', 'Quotes')}
|
||||
count={quotes.length}
|
||||
>
|
||||
{quotes.map((q) => (
|
||||
<Row
|
||||
key={`q-${q.id}`}
|
||||
isCurrent={current.kind === 'quote' && current.id === q.id}
|
||||
href={`/admin/clients/quotes/${q.id}`}
|
||||
number={q.number}
|
||||
statusKey={`quotes.status.${q.status}`}
|
||||
statusFallback={q.status}
|
||||
right={q.totalAmountMinor != null && q.currency
|
||||
? formatMoneyMinor(q.totalAmountMinor, q.currency)
|
||||
: null}
|
||||
meta={q.eventName || undefined}
|
||||
/>
|
||||
))}
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{contracts.length > 0 && (
|
||||
<Group
|
||||
icon={<ScrollText className="w-4 h-4" />}
|
||||
label={t('dealLineage.contracts', 'Contracts')}
|
||||
count={contracts.length}
|
||||
>
|
||||
{contracts.map((c) => (
|
||||
<Row
|
||||
key={`c-${c.id}`}
|
||||
isCurrent={current.kind === 'contract' && current.id === c.id}
|
||||
href={`/admin/clients/contracts/${c.id}`}
|
||||
number={c.number}
|
||||
statusKey={`contracts.status.${c.status}`}
|
||||
statusFallback={c.status}
|
||||
meta={c.title || c.eventName || undefined}
|
||||
/>
|
||||
))}
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{invoices.length > 0 && (
|
||||
<Group
|
||||
icon={<Receipt className="w-4 h-4" />}
|
||||
label={t('dealLineage.invoices', 'Invoices')}
|
||||
count={invoices.length}
|
||||
action={canEditPlan ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowEditPlan(true)}
|
||||
leftIcon={<Pencil className="w-3.5 h-3.5" />}
|
||||
className="ml-auto"
|
||||
>
|
||||
{t('dealLineage.editPlan', 'Edit plan')}
|
||||
</Button>
|
||||
) : undefined}
|
||||
>
|
||||
{invoices.map((i) => {
|
||||
const isStorno = i.invoiceKind === 'storno';
|
||||
const installmentTag = i.installmentTotal && i.installmentTotal > 1
|
||||
? ` · ${i.installmentLabel || `${i.installmentIndex! + 1}/${i.installmentTotal}`}`
|
||||
: '';
|
||||
return (
|
||||
<Row
|
||||
key={`i-${i.id}`}
|
||||
isCurrent={current.kind === 'invoice' && current.id === i.id}
|
||||
href={`/admin/clients/bills/${i.id}`}
|
||||
number={i.number}
|
||||
statusKey={`bills.status.${i.status}`}
|
||||
statusFallback={i.status}
|
||||
right={i.totalAmountMinor != null && i.currency
|
||||
? formatMoneyMinor(i.totalAmountMinor, i.currency)
|
||||
: null}
|
||||
badge={isStorno ? t('bills.kind.storno', 'Storno') as string : undefined}
|
||||
meta={installmentTag.replace(/^ · /, '') || undefined}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{canEditPlan && dealUuid && (
|
||||
<EditInstallmentPlanModal
|
||||
isOpen={showEditPlan}
|
||||
onClose={() => setShowEditPlan(false)}
|
||||
dealUuid={dealUuid}
|
||||
siblings={invoices.map((i) => ({
|
||||
id: i.id,
|
||||
number: i.number,
|
||||
status: i.status,
|
||||
totalAmountMinor: i.totalAmountMinor,
|
||||
installmentIndex: i.installmentIndex,
|
||||
installmentTotal: i.installmentTotal,
|
||||
installmentLabel: i.installmentLabel,
|
||||
installmentTrigger: i.installmentTrigger,
|
||||
installmentOffsetDays: i.installmentOffsetDays,
|
||||
}))}
|
||||
eventDate={invoices.find((i) => i.eventDate)?.eventDate || null}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const Group: React.FC<{
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
count: number;
|
||||
children: React.ReactNode;
|
||||
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">
|
||||
{icon}
|
||||
<span>{label}</span>
|
||||
<span>({count})</span>
|
||||
{action}
|
||||
</div>
|
||||
<ul className="divide-y divide-neutral-200 dark:divide-neutral-700">
|
||||
{children}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
|
||||
const Row: React.FC<{
|
||||
isCurrent: boolean;
|
||||
href: string;
|
||||
number: string;
|
||||
statusKey: string;
|
||||
statusFallback: string;
|
||||
right?: string | null;
|
||||
meta?: string;
|
||||
badge?: string;
|
||||
}> = ({ isCurrent, href, number, statusKey, statusFallback, right, meta, badge }) => {
|
||||
const { t } = useTranslation();
|
||||
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>
|
||||
{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>
|
||||
)}
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
if (isCurrent) {
|
||||
return <li className="opacity-60">{inner}</li>;
|
||||
}
|
||||
return (
|
||||
<li>
|
||||
<Link to={href} className="block hover:bg-neutral-50 dark:hover:bg-neutral-800/40 -mx-2 px-2 rounded">
|
||||
{inner}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
export default DocumentLineageCard;
|
||||
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* <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-muted-theme 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;
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* <EventReminderOverrideCard>
|
||||
*
|
||||
* Per-event override for the pre-event customer reminder (migration
|
||||
* 143). Mounted once on the EventDetailsPage; admin can:
|
||||
* - Disable the reminder for THIS event only (no global flip)
|
||||
* - Override the global "days before" offset (null = inherit)
|
||||
* - Provide a custom body that overrides the resolved template's
|
||||
* body for THIS event only (subject still comes from the template)
|
||||
*
|
||||
* Saves through the existing PUT /api/admin/events/:id endpoint with
|
||||
* the three new whitelisted fields (added 2026-05-25):
|
||||
* - event_reminder_disabled (bool)
|
||||
* - event_reminder_offset_days (int | null)
|
||||
* - event_reminder_body_override (string | null)
|
||||
*
|
||||
* Behaves correctly on pre-migration installs: if the event object
|
||||
* doesn't carry the new fields, the form starts blank and "Reset to
|
||||
* default" is a no-op until admin saves something.
|
||||
*
|
||||
* Strings: every label / hint / button goes through `t()` with a
|
||||
* fallback. The maintainer flagged "no hard coded i18n" in commit #5
|
||||
* — applying that convention strictly from here forward.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'react-toastify';
|
||||
import { Bell, BellOff, Save } from 'lucide-react';
|
||||
import { Button, Card, Input } from '../common';
|
||||
import { api } from '../../config/api';
|
||||
|
||||
export interface EventReminderOverrideCardProps {
|
||||
eventId: number;
|
||||
/** Initial values from the event row; null when unset. */
|
||||
initial: {
|
||||
event_reminder_disabled?: boolean;
|
||||
event_reminder_offset_days?: number | null;
|
||||
event_reminder_body_override?: string | null;
|
||||
};
|
||||
/** Optional callback so the parent can refresh its event query
|
||||
* after a save. */
|
||||
onSaved?: () => void;
|
||||
}
|
||||
|
||||
export const EventReminderOverrideCard: React.FC<EventReminderOverrideCardProps> = ({
|
||||
eventId, initial, onSaved,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [disabled, setDisabled] = useState<boolean>(!!initial.event_reminder_disabled);
|
||||
const [offsetDays, setOffsetDays] = useState<string>(
|
||||
initial.event_reminder_offset_days == null ? '' : String(initial.event_reminder_offset_days),
|
||||
);
|
||||
const [bodyOverride, setBodyOverride] = useState<string>(initial.event_reminder_body_override || '');
|
||||
|
||||
// Reset local state if the parent's `initial` changes (e.g. after
|
||||
// the event refetches following an unrelated save).
|
||||
useEffect(() => {
|
||||
setDisabled(!!initial.event_reminder_disabled);
|
||||
setOffsetDays(initial.event_reminder_offset_days == null ? '' : String(initial.event_reminder_offset_days));
|
||||
setBodyOverride(initial.event_reminder_body_override || '');
|
||||
}, [initial.event_reminder_disabled, initial.event_reminder_offset_days, initial.event_reminder_body_override]);
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async () => {
|
||||
const payload: Record<string, unknown> = {
|
||||
event_reminder_disabled: disabled,
|
||||
};
|
||||
// Empty string → null clears the override and inherits global.
|
||||
if (offsetDays.trim() === '') {
|
||||
payload.event_reminder_offset_days = null;
|
||||
} else {
|
||||
const n = Number(offsetDays);
|
||||
if (!Number.isFinite(n) || n < 0) {
|
||||
throw new Error(t('eventReminderOverride.invalidOffset',
|
||||
'Offset must be a non-negative integer or blank.') as string);
|
||||
}
|
||||
payload.event_reminder_offset_days = Math.floor(n);
|
||||
}
|
||||
payload.event_reminder_body_override = bodyOverride.trim() === '' ? null : bodyOverride;
|
||||
await api.put(`/admin/events/${eventId}`, payload);
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(t('eventReminderOverride.saved', 'Reminder override saved.'));
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-event', eventId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['adminEvent', eventId] });
|
||||
onSaved?.();
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
const e = err as { message?: string; response?: { data?: { error?: string } } };
|
||||
toast.error(
|
||||
e?.response?.data?.error
|
||||
|| e?.message
|
||||
|| t('eventReminderOverride.saveError', 'Could not save reminder override.'),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Card padding="lg" className="mt-4">
|
||||
<div className="flex items-start justify-between gap-3 mb-2 flex-wrap">
|
||||
<div className="flex items-center gap-2">
|
||||
{disabled
|
||||
? <BellOff className="w-5 h-5 text-muted-theme" aria-hidden />
|
||||
: <Bell className="w-5 h-5" aria-hidden />}
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t('eventReminderOverride.title', 'Pre-event reminder')}
|
||||
</h2>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => save.mutate()}
|
||||
isLoading={save.isPending}
|
||||
disabled={save.isPending}
|
||||
leftIcon={<Save className="w-4 h-4" />}
|
||||
>
|
||||
{t('eventReminderOverride.save', 'Save override')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-theme mb-3">
|
||||
{t('eventReminderOverride.help',
|
||||
'Per-event override for the customer reminder. Global on-off + default offset live under Settings → Reminder emails. Anything left blank here inherits the global setting / resolved template.')}
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={disabled}
|
||||
onChange={(e) => setDisabled(e.target.checked)}
|
||||
/>
|
||||
{t('eventReminderOverride.disabledLabel',
|
||||
'Disable the reminder for this event (no email goes out)')}
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={365}
|
||||
label={t('eventReminderOverride.offsetLabel',
|
||||
'Days before the event (override) — leave blank to inherit') as string}
|
||||
value={offsetDays}
|
||||
onChange={(e) => setOffsetDays(e.target.value)}
|
||||
placeholder={t('eventReminderOverride.offsetPlaceholder',
|
||||
'Leave blank to use the global default') as string}
|
||||
disabled={disabled}
|
||||
className="md:w-80"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
{t('eventReminderOverride.bodyOverrideLabel',
|
||||
'Custom body for this event (overrides the resolved template body)')}
|
||||
</label>
|
||||
<textarea
|
||||
rows={6}
|
||||
className="input w-full text-sm"
|
||||
placeholder={t('eventReminderOverride.bodyOverridePlaceholder',
|
||||
'Leave blank to use the template body. Variables like {{customer_name}}, {{event_name}}, {{event_date}} still work here.') as string}
|
||||
value={bodyOverride}
|
||||
onChange={(e) => setBodyOverride(e.target.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventReminderOverrideCard;
|
||||
@@ -0,0 +1,407 @@
|
||||
/**
|
||||
* Hours section card (migration 129).
|
||||
*
|
||||
* Used in two places:
|
||||
* 1. CustomerDetailPage — rendered when the per-customer
|
||||
* `feature_hours_logging` flag is on AND the master `hoursLogging`
|
||||
* flag is on. Sits between the features card and account actions.
|
||||
* 2. The standalone /admin/clients/hours page — admin picks ANY
|
||||
* customer with hours logging enabled, then sees this card.
|
||||
*
|
||||
* Wraps customerAdminService.{list,create,delete,billUnbilled}HourEntries.
|
||||
* All writes go through react-query invalidation so the entry list
|
||||
* refreshes after every action.
|
||||
*/
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'react-toastify';
|
||||
import { Clock } from 'lucide-react';
|
||||
import { Button, Card } from '../common';
|
||||
import { DecimalInput } from '../common/DecimalInput';
|
||||
import { parseLocaleDecimal, parseDuration } from '../../utils/parsers';
|
||||
import { customerAdminService } from '../../services/customerAdmin.service';
|
||||
import { businessProfileService } from '../../services/businessProfile.service';
|
||||
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
||||
|
||||
export interface HoursSectionProps {
|
||||
customerId: number;
|
||||
customerHourlyRateMinor: number | null;
|
||||
billingCadence: 'per_event' | 'monthly' | 'quarterly';
|
||||
onHourlyRateChange?: (next: number | null) => void;
|
||||
/**
|
||||
* When true, render only the entry-history table + the per-event
|
||||
* "Bill these hours" action. Hides the inline log-entry form and
|
||||
* the default-rate input. Used on the customer detail page now
|
||||
* that logging itself lives on the standalone /admin/clients/hours
|
||||
* surface — the detail page becomes a read-only history view with
|
||||
* the on-demand bill action for per-event customers.
|
||||
*/
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export const HoursSection: React.FC<HoursSectionProps> = ({
|
||||
customerId, customerHourlyRateMinor, billingCadence, onHourlyRateChange, compact,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const { format: fmtDate, formatTime: fmtTime, timeFormat } = useLocalizedDate();
|
||||
// `lang` hint on <input type="time"> nudges Chrome/Edge to render the
|
||||
// picker in the matching clock convention (de-DE → 24h, en-US → 12h).
|
||||
// Safari/Firefox follow OS locale and ignore this — that's a browser
|
||||
// limitation, not something we can fix in the page. The underlying
|
||||
// value stays HH:mm (24h) regardless of how the picker presents it.
|
||||
const timeInputLang = timeFormat === '12h' ? 'en-US' : 'de-DE';
|
||||
const [entryDate, setEntryDate] = useState(() => new Date().toISOString().slice(0, 10));
|
||||
const [startTime, setStartTime] = useState('09:00');
|
||||
const [endTime, setEndTime] = useState('10:00');
|
||||
const [duration, setDuration] = useState<string>('');
|
||||
const [rateOverride, setRateOverride] = useState<string>('');
|
||||
const [description, setDescription] = useState('');
|
||||
|
||||
// Duration shortcut — admin types "1.5", "1,5", "1:30" or "1h" and
|
||||
// the end-time jumps to start + duration. Pure convenience; the End
|
||||
// input still works for explicit times. Empty / unparseable input is
|
||||
// a no-op so a typo doesn't overwrite a freshly-edited End.
|
||||
const applyDuration = (raw: string) => {
|
||||
const minutes = parseDuration(raw);
|
||||
if (minutes == null) return;
|
||||
const [hh, mm] = startTime.split(':').map(Number);
|
||||
if (!Number.isFinite(hh) || !Number.isFinite(mm)) return;
|
||||
const totalEnd = Math.min(hh * 60 + mm + minutes, 24 * 60 - 1);
|
||||
const eh = Math.floor(totalEnd / 60).toString().padStart(2, '0');
|
||||
const em = (totalEnd % 60).toString().padStart(2, '0');
|
||||
setEndTime(`${eh}:${em}`);
|
||||
};
|
||||
|
||||
const { data: entries = [], isLoading } = useQuery({
|
||||
queryKey: ['admin-customer-hour-entries', customerId],
|
||||
queryFn: () => customerAdminService.listHourEntries(customerId),
|
||||
enabled: Number.isFinite(customerId) && customerId > 0,
|
||||
});
|
||||
|
||||
// Pull the configured default currency so the hint can show
|
||||
// "{{currency}} 150" instead of the hardcoded "CHF 150". Same cache
|
||||
// key as CustomerDetailPage so a single round-trip serves both
|
||||
// mount points. 5-minute stale window — the value changes via
|
||||
// Settings → Business profile, not during a hours-logging session.
|
||||
const { data: profileSnapshot } = useQuery({
|
||||
queryKey: ['business-profile-snapshot'],
|
||||
queryFn: () => businessProfileService.get(),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
const profileDefaultCurrency = profileSnapshot?.profile?.defaultCurrency || 'CHF';
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () => customerAdminService.createHourEntry(customerId, {
|
||||
entryDate, startTime, endTime,
|
||||
// Locale-tolerant: "12,50" and "12.50" both yield 1250.
|
||||
hourlyRateMinorOverride: (() => {
|
||||
if (!rateOverride) return null;
|
||||
const n = parseLocaleDecimal(rateOverride);
|
||||
return Number.isFinite(n) && n >= 0 ? Math.round(n * 100) : null;
|
||||
})(),
|
||||
description: description || null,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['admin-customer-hour-entries', customerId] });
|
||||
qc.invalidateQueries({ queryKey: ['admin-customer', customerId] });
|
||||
setStartTime('09:00');
|
||||
setEndTime('10:00');
|
||||
setDuration('');
|
||||
setRateOverride('');
|
||||
setDescription('');
|
||||
toast.success(t('customers.hours.toast.created', 'Entry logged'));
|
||||
},
|
||||
onError: (err: any) => {
|
||||
const msg = err?.response?.data?.error || err?.message || 'Failed to log entry';
|
||||
toast.error(msg);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (entryId: number) => customerAdminService.deleteHourEntry(customerId, entryId),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['admin-customer-hour-entries', customerId] });
|
||||
qc.invalidateQueries({ queryKey: ['admin-customer', customerId] });
|
||||
toast.success(t('customers.hours.toast.deleted', 'Entry deleted'));
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast.error(err?.response?.data?.error || 'Failed to delete entry');
|
||||
},
|
||||
});
|
||||
|
||||
const billMutation = useMutation({
|
||||
mutationFn: () => customerAdminService.billUnbilledHourEntries(customerId),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['admin-customer-hour-entries', customerId] });
|
||||
qc.invalidateQueries({ queryKey: ['admin-customer', customerId] });
|
||||
toast.success(t('customers.hours.toast.billed', 'Hours billed'));
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast.error(err?.response?.data?.error || 'Failed to bill hours');
|
||||
},
|
||||
});
|
||||
|
||||
// Single pass — both the count and the money total live behind the
|
||||
// same filter. Memoised so a parent re-render (e.g. the
|
||||
// CustomerDetailPage form state changing) doesn't reshuffle the
|
||||
// entries array in JS on every keystroke.
|
||||
const { unbilledCount, unbilledTotalMajor } = useMemo(() => {
|
||||
let count = 0;
|
||||
let minor = 0;
|
||||
for (const e of entries) {
|
||||
if (e.status !== 'unbilled') continue;
|
||||
count += 1;
|
||||
const rateMinor = e.hourlyRateMinorOverride ?? customerHourlyRateMinor ?? 0;
|
||||
minor += rateMinor * e.durationMinutes / 60;
|
||||
}
|
||||
return { unbilledCount: count, unbilledTotalMajor: minor / 100 };
|
||||
}, [entries, customerHourlyRateMinor]);
|
||||
const isMonthly = billingCadence === 'monthly';
|
||||
|
||||
// Local lockout check — mirrors customerHoursService.isEntryLocked
|
||||
// so the delete button can be disabled before the request is sent.
|
||||
const isLocked = (entry: typeof entries[number]) => {
|
||||
if (!entry.invoiceId) return false;
|
||||
if (entry.invoiceIsMonthlyDraft) return false;
|
||||
if (entry.invoiceStatus !== 'scheduled') return true;
|
||||
if (!entry.invoiceScheduledSendAt) return false;
|
||||
return new Date(entry.invoiceScheduledSendAt).getTime() <= Date.now();
|
||||
};
|
||||
|
||||
return (
|
||||
<Card padding="lg">
|
||||
<h2 className="text-lg font-semibold text-theme mb-1 flex items-center gap-2">
|
||||
<Clock className="w-5 h-5" />
|
||||
{t('customers.hours.section', 'Hours')}
|
||||
</h2>
|
||||
<p className="text-xs text-muted-theme mb-4">
|
||||
{isMonthly
|
||||
? t('customers.hours.monthlyHint',
|
||||
'Entries auto-append to the current monthly draft. Edit / delete remains possible until the scheduler arms the draft for send.')
|
||||
: t('customers.hours.perEventHint',
|
||||
'Logged entries stay unbilled until you click "Create draft invoice" — a standalone draft invoice is generated with one line per entry, ready for you to review before sending.')}
|
||||
</p>
|
||||
|
||||
{/* Default rate — hidden in compact mode (history-only on the
|
||||
customer detail page; admin edits the rate elsewhere). */}
|
||||
{!compact && (
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-theme mb-1">
|
||||
{t('customers.field.hourlyRate', 'Default hourly rate')}
|
||||
</label>
|
||||
<DecimalInput
|
||||
value={customerHourlyRateMinor != null ? customerHourlyRateMinor / 100 : NaN}
|
||||
fractionDigits={2}
|
||||
onChange={(n) => {
|
||||
if (!onHourlyRateChange) return;
|
||||
if (!Number.isFinite(n)) {
|
||||
onHourlyRateChange(null);
|
||||
return;
|
||||
}
|
||||
onHourlyRateChange(Math.max(0, Math.round(n * 100)));
|
||||
}}
|
||||
disabled={!onHourlyRateChange}
|
||||
className="w-40 input"
|
||||
placeholder="150.00"
|
||||
/>
|
||||
<p className="text-xs text-muted-theme mt-1">
|
||||
{t('customers.field.hourlyRateHint',
|
||||
'Major units (e.g. 150.00 for {{currency}} 150). Leave blank to require a per-entry override on every block.',
|
||||
{ currency: profileDefaultCurrency })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Inline log-new-entry form — hidden in compact mode. Logging
|
||||
lives on the standalone /admin/clients/hours surface. */}
|
||||
{!compact && (
|
||||
<div className="border-t border-neutral-200 dark:border-neutral-700 pt-4 mb-4">
|
||||
<h3 className="text-sm font-semibold mb-3">{t('customers.hours.form.title', 'Log new entry')}</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-6 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs text-muted-theme mb-1">
|
||||
{t('customers.hours.form.date', 'Date')}
|
||||
</label>
|
||||
<input type="date" value={entryDate}
|
||||
onChange={(e) => setEntryDate(e.target.value)} className="input w-full" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-muted-theme mb-1">
|
||||
{t('customers.hours.form.start', 'Start')}
|
||||
</label>
|
||||
<input type="time" lang={timeInputLang} value={startTime}
|
||||
onChange={(e) => setStartTime(e.target.value)} className="input w-full" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-muted-theme mb-1">
|
||||
{t('customers.hours.form.end', 'End')}
|
||||
</label>
|
||||
<input type="time" lang={timeInputLang} value={endTime}
|
||||
onChange={(e) => setEndTime(e.target.value)} className="input w-full" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-muted-theme mb-1">
|
||||
{t('customers.hours.form.duration', 'Duration')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
value={duration}
|
||||
onChange={(e) => setDuration(e.target.value)}
|
||||
onBlur={(e) => applyDuration(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
applyDuration((e.target as HTMLInputElement).value);
|
||||
}
|
||||
}}
|
||||
placeholder={t('customers.hours.form.durationPlaceholder', '1h · 1.5 · 1:30') as string}
|
||||
title={t('customers.hours.form.durationHint',
|
||||
'Type a duration to auto-fill End: 1h, 1.5, 1,5 or 1:30') as string}
|
||||
className="input w-full" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-muted-theme mb-1">
|
||||
{t('customers.hours.form.rateOverride', 'Rate override')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
value={rateOverride}
|
||||
onChange={(e) => setRateOverride(e.target.value)}
|
||||
placeholder={customerHourlyRateMinor != null
|
||||
? (customerHourlyRateMinor / 100).toFixed(2)
|
||||
: '—'}
|
||||
className="input w-full" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<label className="block text-xs text-muted-theme mb-1">
|
||||
{t('customers.hours.form.note', 'Note / description')}
|
||||
</label>
|
||||
<textarea rows={2} value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
className="input w-full text-sm"
|
||||
placeholder={t('customers.hours.form.notePlaceholder',
|
||||
'What was worked on?') as string} />
|
||||
</div>
|
||||
<div className="mt-3 flex justify-end">
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={createMutation.isPending}
|
||||
isLoading={createMutation.isPending}
|
||||
onClick={() => createMutation.mutate()}
|
||||
>
|
||||
{t('customers.hours.form.save', 'Add entry')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bill-these-hours button for per-event customers only. Stays
|
||||
visible in compact mode so the customer-detail page can
|
||||
still trigger the on-demand billing action. */}
|
||||
{!isMonthly && unbilledCount > 0 && (
|
||||
<div className="mb-4 flex items-center justify-between bg-blue-50 dark:bg-blue-900/20 rounded p-3">
|
||||
<span className="text-sm">
|
||||
{t('customers.hours.unbilledCount',
|
||||
'{{count}} unbilled entries totaling {{total}}',
|
||||
{
|
||||
count: unbilledCount,
|
||||
total: unbilledTotalMajor.toFixed(2),
|
||||
})}
|
||||
</span>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={billMutation.isPending}
|
||||
isLoading={billMutation.isPending}
|
||||
onClick={() => billMutation.mutate()}
|
||||
>
|
||||
{t('customers.hours.billButton', 'Create draft invoice')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Entry list table. */}
|
||||
{isLoading ? (
|
||||
<p className="text-sm text-muted-theme">{t('common.loading', 'Loading…')}</p>
|
||||
) : entries.length === 0 ? (
|
||||
<p className="text-sm text-muted-theme">
|
||||
{t('customers.hours.empty', 'No entries logged yet.')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-xs uppercase text-muted-theme">
|
||||
<th className="py-2 pr-3">{t('customers.hours.col.date', 'Date')}</th>
|
||||
<th className="py-2 pr-3">{t('customers.hours.col.range', 'Time')}</th>
|
||||
<th className="py-2 pr-3 text-right">{t('customers.hours.col.hours', 'Hours')}</th>
|
||||
<th className="py-2 pr-3 text-right">{t('customers.hours.col.rate', 'Rate')}</th>
|
||||
<th className="py-2 pr-3 text-right">{t('customers.hours.col.total', 'Total')}</th>
|
||||
<th className="py-2 pr-3">{t('customers.hours.col.note', 'Note')}</th>
|
||||
<th className="py-2 pr-3">{t('customers.hours.col.status', 'Status')}</th>
|
||||
<th className="py-2 pr-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map((e) => {
|
||||
const rate = e.hourlyRateMinorOverride ?? customerHourlyRateMinor ?? 0;
|
||||
const hours = e.durationMinutes / 60;
|
||||
const total = (hours * rate) / 100;
|
||||
const locked = isLocked(e);
|
||||
return (
|
||||
<tr key={e.id} className="border-t border-neutral-200 dark:border-neutral-700">
|
||||
<td className="py-1.5 pr-3 tabular-nums">{fmtDate(e.entryDate)}</td>
|
||||
<td className="py-1.5 pr-3 tabular-nums">{fmtTime(e.startTime)}–{fmtTime(e.endTime)}</td>
|
||||
<td className="py-1.5 pr-3 text-right tabular-nums">{hours.toFixed(2)}</td>
|
||||
<td className="py-1.5 pr-3 text-right tabular-nums">{(rate / 100).toFixed(2)}</td>
|
||||
<td className="py-1.5 pr-3 text-right tabular-nums font-medium">{total.toFixed(2)}</td>
|
||||
<td className="py-1.5 pr-3 max-w-xs truncate" title={e.description || ''}>
|
||||
{e.description || '—'}
|
||||
</td>
|
||||
<td className="py-1.5 pr-3">
|
||||
{e.status === 'billed' ? (
|
||||
<span className="text-xs text-green-700 dark:text-green-300">
|
||||
{e.invoiceNumber
|
||||
? t('customers.hours.status.billedOn',
|
||||
'Billed: {{number}}', { number: e.invoiceNumber })
|
||||
: t('customers.hours.status.billed', 'Billed')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-amber-700 dark:text-amber-300">
|
||||
{t('customers.hours.status.unbilled', 'Unbilled')}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-1.5 pr-3 text-right">
|
||||
<button
|
||||
type="button"
|
||||
disabled={locked || deleteMutation.isPending}
|
||||
onClick={() => {
|
||||
if (window.confirm(t('customers.hours.confirmDelete',
|
||||
'Delete this entry? If it has been billed onto a draft, the matching invoice line will also be removed.') as string)) {
|
||||
deleteMutation.mutate(e.id);
|
||||
}
|
||||
}}
|
||||
className="text-xs text-red-600 hover:underline disabled:text-neutral-400 disabled:cursor-not-allowed"
|
||||
title={locked ? t('customers.hours.locked',
|
||||
'Locked: invoice already armed for send') as string : undefined}
|
||||
>
|
||||
{t('common.delete', 'Delete')}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default HoursSection;
|
||||
@@ -0,0 +1,360 @@
|
||||
/**
|
||||
* Inline "+ Create new customer" form mounted inside the quote /
|
||||
* invoice editor's customer card.
|
||||
*
|
||||
* Two save modes:
|
||||
* - "Save as passive customer" → POST /admin/customers. No email,
|
||||
* no invitation. The customer becomes a usable record
|
||||
* immediately for the current quote/invoice.
|
||||
* - "Save & send portal invitation" → POST /admin/customers
|
||||
* followed by POST /admin/customers/:id/send-invite. Customer is
|
||||
* created (passive in DB), then a standard onboarding email is
|
||||
* queued so they can claim portal access. Orchestrated client-
|
||||
* side so the backend endpoints stay simple and single-purpose.
|
||||
*
|
||||
* If the second call fails after the first succeeds, the customer
|
||||
* stays saved (passive) and a warning toast asks the admin to retry
|
||||
* from the customer detail page.
|
||||
*
|
||||
* Field set mirrors the customer detail page so admins see the same
|
||||
* shape regardless of where they're editing.
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'react-toastify';
|
||||
import { Save, Send, X } from 'lucide-react';
|
||||
import { Button, Input } from '../common';
|
||||
import {
|
||||
customerAdminService,
|
||||
type CustomerAccountDetail,
|
||||
type CustomerInvitePrefill,
|
||||
} from '../../services/customerAdmin.service';
|
||||
import { businessProfileService } from '../../services/businessProfile.service';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* Fires after a successful save. The customer payload is the same
|
||||
* shape the customer-detail endpoint returns, so callers can use
|
||||
* its id/email/company directly to populate the quote/invoice's
|
||||
* customer pin.
|
||||
*/
|
||||
onCreated: (customer: CustomerAccountDetail) => void;
|
||||
/** Revert the editor card back to the search-only state. */
|
||||
onCancel: () => void;
|
||||
/**
|
||||
* Which save action(s) the form should expose.
|
||||
* - 'both' (default): renders both buttons — used by the quote /
|
||||
* invoice editors where the admin picks the mode in-place.
|
||||
* - 'passive': renders only "Save as passive customer".
|
||||
* - 'invite': renders only "Save & send portal invitation".
|
||||
* The "passive" / "invite" specialisations let CustomerManagementPage
|
||||
* route both header buttons through the same modal — the only
|
||||
* difference between the two flows is which action button shows.
|
||||
*/
|
||||
mode?: 'both' | 'passive' | 'invite';
|
||||
}
|
||||
|
||||
type FormState = {
|
||||
email: string;
|
||||
salutation: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
displayName: string;
|
||||
phone: string;
|
||||
companyName: string;
|
||||
vatId: string;
|
||||
addressLine1: string;
|
||||
addressLine2: string;
|
||||
postalCode: string;
|
||||
city: string;
|
||||
state: string;
|
||||
countryCode: string;
|
||||
preferredLanguage: string;
|
||||
};
|
||||
|
||||
const empty: FormState = {
|
||||
email: '', salutation: '', firstName: '', lastName: '', displayName: '',
|
||||
phone: '', companyName: '', vatId: '',
|
||||
addressLine1: '', addressLine2: '', postalCode: '', city: '', state: '',
|
||||
countryCode: '', preferredLanguage: '',
|
||||
};
|
||||
|
||||
function buildPrefill(f: FormState): CustomerInvitePrefill {
|
||||
// Backend's PREFILLABLE_FIELDS uses snake_case. Translate at the
|
||||
// wire boundary and drop empty strings so the server doesn't store
|
||||
// "" where null would be more honest.
|
||||
const out: CustomerInvitePrefill = {};
|
||||
if (f.salutation) out.salutation = f.salutation;
|
||||
if (f.firstName) out.first_name = f.firstName;
|
||||
if (f.lastName) out.last_name = f.lastName;
|
||||
if (f.displayName) out.display_name = f.displayName;
|
||||
if (f.phone) out.phone = f.phone;
|
||||
if (f.companyName) out.company_name = f.companyName;
|
||||
if (f.vatId) out.vat_id = f.vatId;
|
||||
if (f.addressLine1) out.address_line1 = f.addressLine1;
|
||||
if (f.addressLine2) out.address_line2 = f.addressLine2;
|
||||
if (f.postalCode) out.postal_code = f.postalCode;
|
||||
if (f.city) out.city = f.city;
|
||||
if (f.state) out.state = f.state;
|
||||
if (f.countryCode) out.country_code = f.countryCode.toUpperCase();
|
||||
if (f.preferredLanguage) out.preferred_language = f.preferredLanguage;
|
||||
return out;
|
||||
}
|
||||
|
||||
export const InlineCustomerCreate: React.FC<Props> = ({ onCreated, onCancel, mode = 'both' }) => {
|
||||
const { t } = useTranslation();
|
||||
const [form, setForm] = useState<FormState>(empty);
|
||||
const [busy, setBusy] = useState<'passive' | 'invite' | null>(null);
|
||||
|
||||
// Resolve a title + subtitle that matches the selected mode. The
|
||||
// 'both' branch keeps the legacy copy so inline (in-editor) callers
|
||||
// see the same wording they had before this prop existed.
|
||||
const heading = mode === 'invite'
|
||||
? {
|
||||
title: t('customers.invite.title', 'Invite a customer'),
|
||||
subtitle: t('customers.invite.description',
|
||||
'They will receive an email with a link to set up their account. Once they have accepted, you can assign them to events.'),
|
||||
}
|
||||
: mode === 'passive'
|
||||
? {
|
||||
title: t('customers.create.openButton', 'Create passive customer'),
|
||||
subtitle: t('customers.create.passiveSubtitle',
|
||||
'Adds an admin-only customer record. The customer is not notified and cannot log in until you send them an invitation later.'),
|
||||
}
|
||||
: {
|
||||
title: t('customers.create.title', 'Create new customer'),
|
||||
subtitle: t('customers.create.subtitle',
|
||||
'Fill in the details below. Choose "Save as passive customer" to create an admin-only record, or "Save & send portal invitation" to also email the customer a sign-up link.'),
|
||||
};
|
||||
|
||||
// Business-profile default locale powers the preferred-language
|
||||
// hint AND seeds the field on mount.
|
||||
const { data: profileSnapshot } = useQuery({
|
||||
queryKey: ['business-profile-snapshot'],
|
||||
queryFn: () => businessProfileService.get(),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
const profileDefaultLocale = profileSnapshot?.profile?.defaultLocale || 'en';
|
||||
|
||||
// Seed preferredLanguage with the profile default once the profile
|
||||
// arrives (only if the field is still empty so we don't clobber
|
||||
// explicit user input).
|
||||
React.useEffect(() => {
|
||||
if (profileDefaultLocale && !form.preferredLanguage) {
|
||||
setForm((prev) => prev.preferredLanguage ? prev : { ...prev, preferredLanguage: profileDefaultLocale });
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [profileDefaultLocale]);
|
||||
|
||||
const setField = (key: keyof FormState) =>
|
||||
(e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) =>
|
||||
setForm((prev) => ({ ...prev, [key]: e.target.value }));
|
||||
|
||||
const isValid = !!form.email && /\S+@\S+\.\S+/.test(form.email);
|
||||
|
||||
const handleSave = async (mode: 'passive' | 'invite') => {
|
||||
if (!isValid) {
|
||||
toast.error(t('customers.create.emailRequired', 'A valid email is required.'));
|
||||
return;
|
||||
}
|
||||
setBusy(mode);
|
||||
try {
|
||||
const customer = await customerAdminService.createDirect(form.email, buildPrefill(form));
|
||||
if (mode === 'invite') {
|
||||
// Customer is now saved as passive. Fire the second call to
|
||||
// promote them. If THIS fails, keep the customer selected
|
||||
// (it exists, just no email went out) and warn the admin.
|
||||
try {
|
||||
await customerAdminService.sendInvite(customer.id);
|
||||
toast.success(t('customers.create.savedActiveToast',
|
||||
'Customer created and portal invitation sent.'));
|
||||
} catch (err: any) {
|
||||
toast.warn(t('customers.create.inviteFailedToast',
|
||||
'Customer saved (passive). Invitation email failed — retry from the customer detail page.'));
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('sendInvite failed', err);
|
||||
}
|
||||
} else {
|
||||
toast.success(t('customers.create.savedPassiveToast',
|
||||
'Passive customer created.'));
|
||||
}
|
||||
onCreated(customer);
|
||||
} catch (err: any) {
|
||||
const msg = err?.response?.data?.error || err?.message || t('common.error', 'Something went wrong.');
|
||||
toast.error(String(msg));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start gap-3 mb-2">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
{heading.title}
|
||||
</h4>
|
||||
<p className="text-xs text-neutral-600 dark:text-neutral-400 mt-0.5">
|
||||
{heading.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="ml-auto p-1 rounded text-neutral-500 hover:text-neutral-900 dark:hover:text-neutral-100"
|
||||
aria-label={t('common.cancel', 'Cancel') as string}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<Input
|
||||
type="email"
|
||||
label={`${t('customers.detail.email', 'Email')} *`}
|
||||
value={form.email}
|
||||
onChange={setField('email')}
|
||||
placeholder="[email protected]"
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label={t('customers.detail.companyName', 'Company name') as string}
|
||||
value={form.companyName}
|
||||
onChange={setField('companyName')}
|
||||
/>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
{t('customers.detail.salutation', 'Salutation')}
|
||||
</label>
|
||||
{/* Salutation values are stored verbatim ("Herr", "Frau",
|
||||
"Mx", "Dr") — canonical tokens across locales. Display
|
||||
labels are translated; the option's value stays in the
|
||||
German form so the same key works regardless of which
|
||||
locale the admin is editing in. Matches CustomerDetailPage. */}
|
||||
<select
|
||||
value={form.salutation}
|
||||
onChange={setField('salutation')}
|
||||
className="w-full rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 px-3 py-2 text-sm text-neutral-900 dark:text-neutral-100"
|
||||
>
|
||||
<option value="">{t('customer.profile.salutation.none', '— Not specified —')}</option>
|
||||
<option value="Herr">{t('customer.profile.salutation.herr', 'Mr.')}</option>
|
||||
<option value="Frau">{t('customer.profile.salutation.frau', 'Ms.')}</option>
|
||||
<option value="Mx">{t('customer.profile.salutation.mx', 'Mx')}</option>
|
||||
<option value="Dr">{t('customer.profile.salutation.dr', 'Dr.')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<Input
|
||||
label={t('customers.detail.phone', 'Phone') as string}
|
||||
value={form.phone}
|
||||
onChange={setField('phone')}
|
||||
/>
|
||||
<Input
|
||||
label={t('customers.detail.firstName', 'First name') as string}
|
||||
value={form.firstName}
|
||||
onChange={setField('firstName')}
|
||||
/>
|
||||
<Input
|
||||
label={t('customers.detail.lastName', 'Last name') as string}
|
||||
value={form.lastName}
|
||||
onChange={setField('lastName')}
|
||||
/>
|
||||
<Input
|
||||
label={t('customers.detail.displayName', 'Display name') as string}
|
||||
value={form.displayName}
|
||||
onChange={setField('displayName')}
|
||||
/>
|
||||
<Input
|
||||
label={t('customers.detail.vatId', 'VAT ID') as string}
|
||||
value={form.vatId}
|
||||
onChange={setField('vatId')}
|
||||
/>
|
||||
<div className="md:col-span-2">
|
||||
<Input
|
||||
label={t('customers.detail.addressLine1', 'Address line 1') as string}
|
||||
value={form.addressLine1}
|
||||
onChange={setField('addressLine1')}
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<Input
|
||||
label={t('customers.detail.addressLine2', 'Address line 2') as string}
|
||||
value={form.addressLine2}
|
||||
onChange={setField('addressLine2')}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
label={t('customers.detail.postalCode', 'Postal code') as string}
|
||||
value={form.postalCode}
|
||||
onChange={setField('postalCode')}
|
||||
/>
|
||||
<Input
|
||||
label={t('customers.detail.city', 'City') as string}
|
||||
value={form.city}
|
||||
onChange={setField('city')}
|
||||
/>
|
||||
<Input
|
||||
label={t('customers.detail.state', 'State / canton') as string}
|
||||
value={form.state}
|
||||
onChange={setField('state')}
|
||||
/>
|
||||
<Input
|
||||
label={t('customers.detail.countryCode', 'Country (ISO code)') as string}
|
||||
value={form.countryCode}
|
||||
onChange={setField('countryCode')}
|
||||
placeholder="CH"
|
||||
maxLength={2}
|
||||
/>
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
{t('customers.detail.preferredLanguage', 'Preferred language')}
|
||||
</label>
|
||||
<select
|
||||
value={form.preferredLanguage || ''}
|
||||
onChange={setField('preferredLanguage')}
|
||||
className="w-full rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 px-3 py-2 text-sm text-neutral-900 dark:text-neutral-100"
|
||||
>
|
||||
<option value="en">English</option>
|
||||
<option value="de">Deutsch</option>
|
||||
<option value="fr">Français</option>
|
||||
<option value="nl">Nederlands</option>
|
||||
<option value="pt">Português</option>
|
||||
<option value="ru">Русский</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-end gap-2 pt-2 border-t border-neutral-200 dark:border-neutral-700">
|
||||
<Button variant="outline" onClick={onCancel} disabled={busy !== null}>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</Button>
|
||||
{(mode === 'both' || mode === 'passive') && (
|
||||
<Button
|
||||
variant={mode === 'passive' ? 'primary' : 'outline'}
|
||||
onClick={() => handleSave('passive')}
|
||||
disabled={busy !== null || !isValid}
|
||||
isLoading={busy === 'passive'}
|
||||
leftIcon={<Save className="w-4 h-4" />}
|
||||
>
|
||||
{/* Mode 'passive' is the dedicated CTA: promote it to the
|
||||
primary variant so the button hierarchy mirrors what
|
||||
an admin who opened the modal from "Create passive
|
||||
customer" expects. */}
|
||||
{t('customers.create.saveAsPassive', 'Save as passive customer')}
|
||||
</Button>
|
||||
)}
|
||||
{(mode === 'both' || mode === 'invite') && (
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => handleSave('invite')}
|
||||
disabled={busy !== null || !isValid}
|
||||
isLoading={busy === 'invite'}
|
||||
leftIcon={<Send className="w-4 h-4" />}
|
||||
>
|
||||
{t('customers.create.saveAndInvite', 'Save & send portal invitation')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,320 @@
|
||||
/**
|
||||
* <InstallmentsPanel> — shared editor surface for the per-document
|
||||
* installment plan. Used by both QuoteEditorPage and BillEditorPage.
|
||||
*
|
||||
* Two render modes:
|
||||
* - Simple (default): per-row date picker mapped to trigger='fixed_date'
|
||||
* - Advanced (toggle): per-row trigger dropdown + offset_days
|
||||
*
|
||||
* Rows store the canonical {label, percent, trigger, offset_days}
|
||||
* shape (PaymentTermInstallment from quotes.service.ts). Date pickers
|
||||
* are a UX convenience layered over `trigger='fixed_date' + offset_days`.
|
||||
*
|
||||
* When the panel is "off" (`value: null`), the document is treated as
|
||||
* a single-invoice / single-payment plan and the parent editor skips
|
||||
* the installment field on save. Switching the panel on inserts a
|
||||
* default 100% row pre-populated from `useInstallmentDefaults()`.
|
||||
*
|
||||
* Validation: percents must sum to 100 (rendered inline below the
|
||||
* footer; parent uses `onValidityChange` to disable Save when wrong).
|
||||
*
|
||||
* The parent owns the `value` state; this component is fully
|
||||
* controlled. Render is React-Strict-Mode safe (no state in refs that
|
||||
* outlives the props).
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Trash2, Plus } from 'lucide-react';
|
||||
import { Button, Input } from '../common';
|
||||
import type { PaymentTermInstallment } from '../../services/quotes.service';
|
||||
import { useInstallmentDefaults } from '../../hooks/useInstallmentDefaults';
|
||||
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
||||
|
||||
export type InstallmentPlan = PaymentTermInstallment[];
|
||||
|
||||
export interface InstallmentsPanelProps {
|
||||
/** null = single-document mode (no installments). Array = the plan. */
|
||||
value: InstallmentPlan | null;
|
||||
onChange: (next: InstallmentPlan | null) => void;
|
||||
/** Reports whether percents sum to 100. Parent uses to gate Save. */
|
||||
onValidityChange?: (valid: boolean) => void;
|
||||
/** Event date used for trigger preview text in advanced mode. */
|
||||
eventDate?: string | null;
|
||||
/** Disable inputs (e.g. document is locked / sent). */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const ALL_TRIGGERS: PaymentTermInstallment['trigger'][] = [
|
||||
'quote_accepted', 'before_event', 'after_event', 'after_delivery', 'fixed_date',
|
||||
];
|
||||
|
||||
function todayIso(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function addDays(base: string, days: number): string {
|
||||
const d = new Date(base);
|
||||
d.setDate(d.getDate() + days);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function daysBetween(from: string, to: string): number {
|
||||
const f = new Date(from);
|
||||
const t = new Date(to);
|
||||
return Math.round((t.getTime() - f.getTime()) / 86_400_000);
|
||||
}
|
||||
|
||||
export const InstallmentsPanel: React.FC<InstallmentsPanelProps> = ({
|
||||
value, onChange, onValidityChange, eventDate, disabled,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { dateInputLang } = useLocalizedDate();
|
||||
const defaults = useInstallmentDefaults();
|
||||
const [advanced, setAdvanced] = React.useState(false);
|
||||
|
||||
const enabled = value !== null;
|
||||
const rows = value || [];
|
||||
|
||||
const totalPercent = useMemo(
|
||||
() => rows.reduce((s, r) => s + (Number(r.percent) || 0), 0),
|
||||
[rows],
|
||||
);
|
||||
const isValid = !enabled || Math.abs(totalPercent - 100) < 0.001;
|
||||
React.useEffect(() => {
|
||||
onValidityChange?.(isValid);
|
||||
}, [isValid, onValidityChange]);
|
||||
|
||||
const update = (idx: number, patch: Partial<PaymentTermInstallment>) => {
|
||||
const next = rows.map((r, i) => (i === idx ? { ...r, ...patch } : r));
|
||||
onChange(next);
|
||||
};
|
||||
|
||||
const remove = (idx: number) => {
|
||||
const next = rows.filter((_, i) => i !== idx);
|
||||
onChange(next.length === 0 ? null : next);
|
||||
};
|
||||
|
||||
const addRow = () => {
|
||||
// Default-shape new row: use the admin's defaults via a positional
|
||||
// heuristic — first row inherits the "first installment" trigger,
|
||||
// middle rows default to before_event, last row inherits after_event.
|
||||
let trigger: PaymentTermInstallment['trigger'] = defaults.triggerFirst;
|
||||
let offsetDays = 0;
|
||||
if (rows.length === 0) {
|
||||
trigger = defaults.triggerFirst;
|
||||
offsetDays = 0;
|
||||
} else {
|
||||
// Second row onwards. If there's already a row tagged after_event,
|
||||
// a new one slots in as before_event; else default to after_event.
|
||||
const hasAfterEvent = rows.some((r) => r.trigger === 'after_event');
|
||||
trigger = hasAfterEvent ? 'before_event' : 'after_event';
|
||||
offsetDays = hasAfterEvent ? -defaults.daysBeforeEvent : defaults.daysAfterEvent;
|
||||
}
|
||||
// Suggest a percent that fills the gap (clamped to 0-100).
|
||||
const gap = Math.max(0, 100 - totalPercent);
|
||||
const next: PaymentTermInstallment = {
|
||||
label: '',
|
||||
percent: gap > 0 ? gap : 0,
|
||||
trigger,
|
||||
offset_days: offsetDays,
|
||||
};
|
||||
onChange([...rows, next]);
|
||||
};
|
||||
|
||||
const toggleEnabled = () => {
|
||||
if (enabled) {
|
||||
onChange(null);
|
||||
return;
|
||||
}
|
||||
// Switch on: seed with one 100% row using the "first installment"
|
||||
// default trigger so the panel doesn't open with an empty list.
|
||||
onChange([{
|
||||
label: t('installments.firstRowDefaultLabel', 'Anzahlung') as string,
|
||||
percent: 100,
|
||||
trigger: defaults.triggerFirst,
|
||||
offset_days: 0,
|
||||
}]);
|
||||
};
|
||||
|
||||
// Map a row to its "Send on" date in simple mode. Only meaningful
|
||||
// when trigger is fixed_date; for dynamic triggers we display the
|
||||
// resolved date if we have eventDate, else show a placeholder.
|
||||
const previewDate = (row: PaymentTermInstallment): string | null => {
|
||||
const baseline = todayIso();
|
||||
if (row.trigger === 'fixed_date' || row.trigger === 'quote_accepted') {
|
||||
return addDays(baseline, row.offset_days || 0);
|
||||
}
|
||||
if ((row.trigger === 'before_event' || row.trigger === 'after_event') && eventDate) {
|
||||
return addDays(eventDate, row.offset_days || 0);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between gap-2 mb-2 flex-wrap">
|
||||
<label className="flex items-center gap-2 text-sm font-medium cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
onChange={toggleEnabled}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{t('installments.enableLabel', 'Split into installments')}
|
||||
</label>
|
||||
{enabled && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-primary-600 dark:text-primary-400 hover:underline"
|
||||
onClick={() => setAdvanced((v) => !v)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{advanced
|
||||
? t('installments.simpleToggle', 'Use simple date picker')
|
||||
: t('installments.advancedToggle', 'Use dynamic triggers')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{enabled && (
|
||||
<>
|
||||
<p className="text-xs text-muted-theme 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.')
|
||||
: t('installments.simpleHint',
|
||||
'Each row fires on a specific date. Switch to dynamic triggers for plans tied to event date or delivery.')}
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
{rows.map((row, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
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">
|
||||
{t('installments.percent', '%')}
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
step="0.01"
|
||||
value={row.percent}
|
||||
onChange={(e) => update(idx, { percent: Number(e.target.value) })}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-4">
|
||||
<label className="block text-xs text-muted-theme mb-1">
|
||||
{t('installments.label', 'Label')}
|
||||
</label>
|
||||
<Input
|
||||
value={row.label}
|
||||
onChange={(e) => update(idx, { label: e.target.value })}
|
||||
disabled={disabled}
|
||||
placeholder={t('installments.labelPlaceholder', 'Anzahlung / vor Event …') as string}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!advanced ? (
|
||||
<div className="col-span-5">
|
||||
<label className="block text-xs text-muted-theme mb-1">
|
||||
{t('installments.sendOn', 'Send on')}
|
||||
</label>
|
||||
{row.trigger === 'after_delivery' ? (
|
||||
<div className="text-xs text-muted-theme py-2">
|
||||
{t('installments.onDeliveryHint',
|
||||
'On delivery — admin releases manually. Switch to advanced to change.')}
|
||||
</div>
|
||||
) : (
|
||||
<Input
|
||||
type="date"
|
||||
lang={dateInputLang}
|
||||
value={previewDate(row) || ''}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value;
|
||||
if (!next) return;
|
||||
const offset = daysBetween(todayIso(), next);
|
||||
update(idx, { trigger: 'fixed_date', offset_days: offset });
|
||||
}}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="col-span-3">
|
||||
<label className="block text-xs text-muted-theme mb-1">
|
||||
{t('installments.trigger', 'Trigger')}
|
||||
</label>
|
||||
<select
|
||||
value={row.trigger}
|
||||
onChange={(e) => update(idx, {
|
||||
trigger: e.target.value as PaymentTermInstallment['trigger'],
|
||||
offset_days: e.target.value === 'after_delivery' ? 0 : row.offset_days,
|
||||
})}
|
||||
disabled={disabled}
|
||||
className="w-full px-3 py-2 rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-sm"
|
||||
>
|
||||
{ALL_TRIGGERS.map((tr) => (
|
||||
<option key={tr} value={tr}>
|
||||
{t(`installments.triggerOption.${tr}`, tr)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="block text-xs text-muted-theme mb-1">
|
||||
{t('installments.offsetDays', 'Offset (days)')}
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={row.offset_days}
|
||||
onChange={(e) => update(idx, { offset_days: Number(e.target.value) })}
|
||||
disabled={disabled || row.trigger === 'after_delivery'}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="col-span-1 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remove(idx)}
|
||||
disabled={disabled}
|
||||
className="p-2 rounded hover:bg-neutral-200 dark:hover:bg-neutral-700 text-red-600"
|
||||
aria-label={t('installments.removeRow', 'Remove row') as string}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mt-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addRow}
|
||||
disabled={disabled || totalPercent >= 100}
|
||||
leftIcon={<Plus className="w-4 h-4" />}
|
||||
>
|
||||
{t('installments.addRow', 'Add installment')}
|
||||
</Button>
|
||||
<div className={`text-sm font-medium ${isValid ? 'text-green-700 dark:text-green-300' : 'text-red-700 dark:text-red-300'}`}>
|
||||
{t('installments.total', 'Total')}: {totalPercent.toFixed(2)}%
|
||||
{!isValid && ` — ${t('installments.mustSumTo100', 'must sum to 100%')}`}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default InstallmentsPanel;
|
||||
@@ -0,0 +1,481 @@
|
||||
/**
|
||||
* Reusable line-items editor for quotes + invoices.
|
||||
*
|
||||
* Two-level hierarchy (migration 119):
|
||||
* - Top-level items roll into the document net/VAT/total.
|
||||
* - Sub-items render indented under their parent. Their line total
|
||||
* is shown in parentheses for transparency but is display-only;
|
||||
* only the parent's price contributes to net.
|
||||
* - Per-item `detailsText` is an optional free-form notes block
|
||||
* rendered below the description on the PDF + customer view.
|
||||
*
|
||||
* Items in `items` are kept in DISPLAY ORDER (parent immediately
|
||||
* followed by its sub-items, then the next parent, etc.). `position`
|
||||
* is a stable unique identifier used to link sub-items to parents in
|
||||
* the payload — once assigned at row creation we never renumber it.
|
||||
* Move up/down only swaps within the same level (top-level among
|
||||
* top-level, sub-items among siblings of the same parent).
|
||||
*
|
||||
* Money values are stored in MAJOR units in the form state (e.g. 250.00)
|
||||
* for editor ergonomics, then converted to minor (25000) when persisting.
|
||||
* The conversion happens at the save boundary in the parent page.
|
||||
*/
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Plus, X, ArrowUp, ArrowDown, Save as SaveIcon, ChevronDown, ChevronRight, CornerDownRight } from 'lucide-react';
|
||||
import { Button } from '../common';
|
||||
import { DecimalInput } from '../common/DecimalInput';
|
||||
import { formatMoney } from '../../utils/money';
|
||||
|
||||
export interface EditableLineItem {
|
||||
id?: number;
|
||||
/** Stable unique identifier used to link sub-items to parents. */
|
||||
position: number;
|
||||
quantity: number;
|
||||
description: string;
|
||||
/** Stored in major units (CHF / EUR) for UX. */
|
||||
unitPrice: number;
|
||||
discountPercent: number;
|
||||
/** NULL = top-level (rolls into net). Non-null = sub-item under that parent's position. */
|
||||
parentPosition?: number | null;
|
||||
/** Optional free-form notes rendered below the description. */
|
||||
detailsText?: string;
|
||||
}
|
||||
|
||||
export interface LineItemPresetMinimal {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
unitPriceMinor: number;
|
||||
quantityDefault: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
items: EditableLineItem[];
|
||||
currency: string;
|
||||
showDiscount?: boolean;
|
||||
vatRate?: number;
|
||||
shippingAmount?: number;
|
||||
onChange: (items: EditableLineItem[]) => void;
|
||||
presets?: LineItemPresetMinimal[];
|
||||
onSaveAsPreset?: (item: EditableLineItem) => void;
|
||||
}
|
||||
|
||||
function nextFreshPosition(items: EditableLineItem[]) {
|
||||
return items.reduce((m, it) => Math.max(m, it.position), 0) + 1;
|
||||
}
|
||||
|
||||
function isSub(li: EditableLineItem) {
|
||||
return li.parentPosition != null;
|
||||
}
|
||||
|
||||
export const LineItemsTable: React.FC<Props> = ({
|
||||
items, currency, showDiscount = true, vatRate = 0, shippingAmount = 0,
|
||||
onChange, presets = [], onSaveAsPreset,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Track which rows have the details textarea expanded. Keyed by
|
||||
// `position` since that's stable across renders.
|
||||
const [detailsOpen, setDetailsOpen] = useState<Set<number>>(() => new Set(
|
||||
items.filter((it) => it.detailsText && it.detailsText.trim().length > 0).map((it) => it.position)
|
||||
));
|
||||
const toggleDetails = (pos: number) => {
|
||||
setDetailsOpen((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(pos)) next.delete(pos); else next.add(pos);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const setItem = (idx: number, patch: Partial<EditableLineItem>) => {
|
||||
const next = items.map((it, i) => (i === idx ? { ...it, ...patch } : it));
|
||||
onChange(next);
|
||||
};
|
||||
|
||||
const addRow = (preset?: LineItemPresetMinimal) => {
|
||||
const pos = nextFreshPosition(items);
|
||||
const next = [...items, {
|
||||
position: pos,
|
||||
quantity: preset ? Number(preset.quantityDefault) || 1 : 1,
|
||||
description: preset ? `${preset.name}${preset.description ? `\n${preset.description}` : ''}` : '',
|
||||
unitPrice: preset ? Number(preset.unitPriceMinor) / 100 : 0,
|
||||
discountPercent: 0,
|
||||
parentPosition: null,
|
||||
detailsText: '',
|
||||
}];
|
||||
onChange(next);
|
||||
};
|
||||
|
||||
/**
|
||||
* Insert a fresh sub-item immediately AFTER the parent's last
|
||||
* existing sub-item (or the parent itself if there are none yet).
|
||||
* Keeps display order grouped: parent → its sub-items → next parent.
|
||||
*/
|
||||
const addSubItem = (parentIdx: number) => {
|
||||
const parent = items[parentIdx];
|
||||
if (!parent || isSub(parent)) return; // can't nest under a sub-item (1 level deep)
|
||||
let insertAt = parentIdx + 1;
|
||||
while (insertAt < items.length && items[insertAt].parentPosition === parent.position) {
|
||||
insertAt += 1;
|
||||
}
|
||||
const newRow: EditableLineItem = {
|
||||
position: nextFreshPosition(items),
|
||||
quantity: 1,
|
||||
description: '',
|
||||
unitPrice: 0,
|
||||
discountPercent: 0,
|
||||
parentPosition: parent.position,
|
||||
detailsText: '',
|
||||
};
|
||||
const next = [...items];
|
||||
next.splice(insertAt, 0, newRow);
|
||||
onChange(next);
|
||||
};
|
||||
|
||||
/**
|
||||
* Remove a row. When removing a top-level parent, also sweep its
|
||||
* sub-items (CASCADE-equivalent in the editor, matches the DB FK
|
||||
* cascade so the editor's behaviour matches what would persist).
|
||||
*/
|
||||
const removeRow = (idx: number) => {
|
||||
const target = items[idx];
|
||||
if (!target) return;
|
||||
if (!isSub(target)) {
|
||||
onChange(items.filter((it, i) => i !== idx && it.parentPosition !== target.position));
|
||||
} else {
|
||||
onChange(items.filter((_, i) => i !== idx));
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Move up/down — restricted to siblings of the same level. For
|
||||
* top-level items, the entire "group" (parent + its sub-items) is
|
||||
* moved as a unit. For sub-items, the swap is within the same
|
||||
* parent's children only.
|
||||
*/
|
||||
const move = (idx: number, dir: -1 | 1) => {
|
||||
const target = items[idx];
|
||||
if (!target) return;
|
||||
if (isSub(target)) {
|
||||
// Find sibling sub-items with same parent.
|
||||
const siblings: number[] = [];
|
||||
for (let i = 0; i < items.length; i += 1) {
|
||||
if (items[i].parentPosition === target.parentPosition) siblings.push(i);
|
||||
}
|
||||
const here = siblings.indexOf(idx);
|
||||
const other = here + dir;
|
||||
if (other < 0 || other >= siblings.length) return;
|
||||
const next = [...items];
|
||||
[next[siblings[here]], next[siblings[other]]] = [next[siblings[other]], next[siblings[here]]];
|
||||
onChange(next);
|
||||
} else {
|
||||
// Move top-level group as a block. Find the range of this group
|
||||
// and the adjacent group's range, then swap them.
|
||||
const groupStart = idx;
|
||||
let groupEnd = idx + 1;
|
||||
while (groupEnd < items.length && items[groupEnd].parentPosition === target.position) {
|
||||
groupEnd += 1;
|
||||
}
|
||||
if (dir === -1) {
|
||||
if (groupStart === 0) return;
|
||||
// Find the previous top-level item's group range.
|
||||
let prevTopIdx = groupStart - 1;
|
||||
while (prevTopIdx > 0 && isSub(items[prevTopIdx])) prevTopIdx -= 1;
|
||||
const prevGroupStart = prevTopIdx;
|
||||
const prevGroupEnd = groupStart; // exclusive
|
||||
const before = items.slice(0, prevGroupStart);
|
||||
const prevGroup = items.slice(prevGroupStart, prevGroupEnd);
|
||||
const thisGroup = items.slice(groupStart, groupEnd);
|
||||
const after = items.slice(groupEnd);
|
||||
onChange([...before, ...thisGroup, ...prevGroup, ...after]);
|
||||
} else {
|
||||
if (groupEnd >= items.length) return;
|
||||
const nextTopIdx = groupEnd; // is a top-level by construction
|
||||
let nextGroupEnd = nextTopIdx + 1;
|
||||
while (nextGroupEnd < items.length && isSub(items[nextGroupEnd])) nextGroupEnd += 1;
|
||||
const before = items.slice(0, groupStart);
|
||||
const thisGroup = items.slice(groupStart, groupEnd);
|
||||
const nextGroup = items.slice(nextTopIdx, nextGroupEnd);
|
||||
const after = items.slice(nextGroupEnd);
|
||||
onChange([...before, ...nextGroup, ...thisGroup, ...after]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const rawLineTotal = (li: EditableLineItem) =>
|
||||
Math.round(li.quantity * li.unitPrice * (1 - li.discountPercent / 100) * 100) / 100;
|
||||
|
||||
/**
|
||||
* A parent has "priced sub-items" when at least one of its
|
||||
* children has unitPrice > 0. In that mode the parent's own
|
||||
* unit_price / qty / discount inputs are disabled and its line
|
||||
* total auto-resolves to the sum of those priced sub-items.
|
||||
* Matches the backend resolveParentTotalsFromSubItems() rule
|
||||
* (migration 119) so the editor mirrors what gets persisted.
|
||||
*/
|
||||
// D.4 — memoize the per-parent child-pricing aggregates. The previous
|
||||
// shape rescanned `items` on every call, and the helpers were called
|
||||
// inside the JSX loop AND from the subtotal reduce — so on a 20-item
|
||||
// quote each keystroke ran ~O(n²) array scans. Build a Map once per
|
||||
// render and read O(1) afterwards.
|
||||
const childPricingByParent = useMemo(() => {
|
||||
const map = new Map<number, { hasPriced: boolean; pricedSum: number }>();
|
||||
for (const c of items) {
|
||||
if (c.parentPosition == null) continue;
|
||||
if (!(c.unitPrice > 0)) continue;
|
||||
const cur = map.get(c.parentPosition) || { hasPriced: false, pricedSum: 0 };
|
||||
cur.hasPriced = true;
|
||||
cur.pricedSum += rawLineTotal(c);
|
||||
map.set(c.parentPosition, cur);
|
||||
}
|
||||
return map;
|
||||
// rawLineTotal is a pure function of the closure's `items`; the
|
||||
// dependency array gets the items snapshot directly.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [items]);
|
||||
const hasPricedChildren = (parentPos: number) =>
|
||||
childPricingByParent.get(parentPos)?.hasPriced || false;
|
||||
const pricedChildrenSum = (parentPos: number) =>
|
||||
childPricingByParent.get(parentPos)?.pricedSum || 0;
|
||||
|
||||
/** Resolved line total: parent auto-sums when sub-items are priced. */
|
||||
const lineTotal = (li: EditableLineItem) => {
|
||||
if (!isSub(li) && hasPricedChildren(li.position)) {
|
||||
return pricedChildrenSum(li.position);
|
||||
}
|
||||
return rawLineTotal(li);
|
||||
};
|
||||
|
||||
// Subtotal: top-level items ONLY (their resolved totals). Sub-items
|
||||
// never roll directly into net — they only feed their parent's
|
||||
// auto-resolved line total.
|
||||
const subtotal = items.filter((li) => !isSub(li)).reduce((s, li) => s + lineTotal(li), 0);
|
||||
const vatAmount = Math.round(subtotal * vatRate) / 100;
|
||||
const total = subtotal + vatAmount + (Number(shippingAmount) || 0);
|
||||
|
||||
// Display numbering: top-level items get 1, 2, 3...; sub-items
|
||||
// render as N.1, N.2 under the parent for clarity.
|
||||
const displayNumbers = (() => {
|
||||
const out: string[] = [];
|
||||
let topCount = 0;
|
||||
let subCount = 0;
|
||||
for (const li of items) {
|
||||
if (!isSub(li)) {
|
||||
topCount += 1;
|
||||
subCount = 0;
|
||||
out.push(String(topCount));
|
||||
} else {
|
||||
subCount += 1;
|
||||
out.push(`${topCount}.${subCount}`);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
})();
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="overflow-x-auto rounded-lg border border-neutral-200 dark:border-neutral-700">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-neutral-50 dark:bg-neutral-800 text-neutral-700 dark:text-neutral-300">
|
||||
<tr>
|
||||
<th className="px-2 py-2 text-left w-14">{t('crm.lineItems.position', 'Pos.')}</th>
|
||||
<th className="px-2 py-2 text-left w-20">{t('crm.lineItems.quantity', 'Anzahl')}</th>
|
||||
<th className="px-2 py-2 text-left">{t('crm.lineItems.description', 'Beschreibung')}</th>
|
||||
<th className="px-2 py-2 text-right w-28">{t('crm.lineItems.unitPrice', 'Einzelpreis')}</th>
|
||||
{showDiscount && (
|
||||
<th className="px-2 py-2 text-right w-24">{t('crm.lineItems.discount', 'Rabatt %')}</th>
|
||||
)}
|
||||
<th className="px-2 py-2 text-right w-28">{t('crm.lineItems.total', 'Summe')}</th>
|
||||
<th className="px-2 py-2 w-28"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((li, idx) => {
|
||||
const sub = isSub(li);
|
||||
const open = detailsOpen.has(li.position);
|
||||
// Parent is "auto-totaled" when at least one of its
|
||||
// sub-items has a price. In that mode the qty / unit
|
||||
// price / discount inputs are disabled — the parent's
|
||||
// total is the sum of priced sub-items, computed by
|
||||
// the backend on save.
|
||||
const parentAutoTotaled = !sub && hasPricedChildren(li.position);
|
||||
const disabledInputClass = parentAutoTotaled
|
||||
? 'bg-neutral-100 dark:bg-neutral-700 text-neutral-400 cursor-not-allowed'
|
||||
: 'bg-white dark:bg-neutral-800';
|
||||
return (
|
||||
<React.Fragment key={li.position}>
|
||||
<tr className={`border-t border-neutral-200 dark:border-neutral-700 ${
|
||||
sub ? 'bg-neutral-50/60 dark:bg-neutral-900/40' : ''
|
||||
}`}>
|
||||
<td className="px-2 py-2 text-neutral-600 dark:text-neutral-400 align-top">
|
||||
<div className="flex items-center gap-1">
|
||||
{sub && <CornerDownRight className="w-3.5 h-3.5 text-neutral-400" aria-hidden />}
|
||||
<span>{displayNumbers[idx]}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-2 py-2 align-top">
|
||||
<DecimalInput
|
||||
className={`w-20 rounded border border-neutral-300 dark:border-neutral-600 px-2 py-1 text-sm ${disabledInputClass}`}
|
||||
value={li.quantity}
|
||||
onChange={(n) => setItem(idx, { quantity: Number.isFinite(n) ? n : 0 })}
|
||||
disabled={parentAutoTotaled}
|
||||
title={parentAutoTotaled ? t('crm.lineItems.autoTotaledHint', 'Total auto-computed from sub-items below') as string : undefined}
|
||||
/>
|
||||
</td>
|
||||
<td className={`px-2 py-2 align-top ${sub ? 'pl-6' : ''}`}>
|
||||
<textarea
|
||||
rows={2}
|
||||
className="w-full rounded border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 px-2 py-1 text-sm"
|
||||
value={li.description}
|
||||
onChange={(e) => setItem(idx, { description: e.target.value })}
|
||||
placeholder={t('crm.lineItems.descriptionPlaceholder', 'Description (multi-line OK)') as string}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleDetails(li.position)}
|
||||
className="mt-1 inline-flex items-center gap-1 text-xs text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300"
|
||||
>
|
||||
{open
|
||||
? <ChevronDown className="w-3.5 h-3.5" aria-hidden />
|
||||
: <ChevronRight className="w-3.5 h-3.5" aria-hidden />}
|
||||
<span>
|
||||
{(li.detailsText && li.detailsText.trim().length > 0)
|
||||
? t('crm.lineItems.detailsFilled', 'Details')
|
||||
: t('crm.lineItems.detailsAdd', '+ Add details / notes')}
|
||||
</span>
|
||||
</button>
|
||||
{open && (
|
||||
<textarea
|
||||
rows={2}
|
||||
maxLength={2000}
|
||||
className="mt-2 w-full rounded border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 px-2 py-1 text-xs italic"
|
||||
value={li.detailsText || ''}
|
||||
onChange={(e) => setItem(idx, { detailsText: e.target.value })}
|
||||
placeholder={t('crm.lineItems.detailsPlaceholder', 'Optional notes — fine print, package inclusions, conditions…') as string}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-2 py-2 align-top">
|
||||
<DecimalInput
|
||||
className={`w-24 rounded border border-neutral-300 dark:border-neutral-600 px-2 py-1 text-sm text-right ${disabledInputClass}`}
|
||||
value={li.unitPrice}
|
||||
fractionDigits={2}
|
||||
onChange={(n) => setItem(idx, { unitPrice: Number.isFinite(n) ? n : 0 })}
|
||||
disabled={parentAutoTotaled}
|
||||
title={parentAutoTotaled ? t('crm.lineItems.autoTotaledHint', 'Total auto-computed from sub-items below') as string : undefined}
|
||||
/>
|
||||
</td>
|
||||
{showDiscount && (
|
||||
<td className="px-2 py-2 align-top">
|
||||
<DecimalInput
|
||||
className={`w-20 rounded border border-neutral-300 dark:border-neutral-600 px-2 py-1 text-sm text-right ${disabledInputClass}`}
|
||||
value={li.discountPercent}
|
||||
onChange={(n) => {
|
||||
// Clamp to 0..100 — match the original input's min/max.
|
||||
const clamped = !Number.isFinite(n) ? 0 : Math.max(0, Math.min(100, n));
|
||||
setItem(idx, { discountPercent: clamped });
|
||||
}}
|
||||
disabled={parentAutoTotaled}
|
||||
title={parentAutoTotaled ? t('crm.lineItems.autoTotaledHint', 'Total auto-computed from sub-items below') as string : undefined}
|
||||
/>
|
||||
</td>
|
||||
)}
|
||||
<td className={`px-2 py-2 text-right tabular-nums align-top ${
|
||||
sub
|
||||
? 'text-neutral-500 dark:text-neutral-400 italic'
|
||||
: 'font-medium'
|
||||
}`}>
|
||||
{sub
|
||||
? li.unitPrice > 0
|
||||
? `(${formatMoney(lineTotal(li), currency)})`
|
||||
: ''
|
||||
: formatMoney(lineTotal(li), currency)}
|
||||
{parentAutoTotaled && (
|
||||
<div className="text-[10px] font-normal text-neutral-500 dark:text-neutral-400 italic mt-0.5">
|
||||
{t('crm.lineItems.autoTotaledNote', '= Σ Unterpositionen') as string}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-2 py-2 align-top">
|
||||
<div className="flex items-center gap-1 justify-end flex-wrap">
|
||||
<button type="button" onClick={() => move(idx, -1)} aria-label="Move up"
|
||||
className="p-1 rounded hover:bg-neutral-100 dark:hover:bg-neutral-700 disabled:opacity-30">
|
||||
<ArrowUp className="w-4 h-4" />
|
||||
</button>
|
||||
<button type="button" onClick={() => move(idx, 1)} aria-label="Move down"
|
||||
className="p-1 rounded hover:bg-neutral-100 dark:hover:bg-neutral-700 disabled:opacity-30">
|
||||
<ArrowDown className="w-4 h-4" />
|
||||
</button>
|
||||
{!sub && (
|
||||
<button type="button" onClick={() => addSubItem(idx)} aria-label="Add sub-item"
|
||||
className="p-1 rounded hover:bg-neutral-100 dark:hover:bg-neutral-700"
|
||||
title={t('crm.lineItems.addSubItem', 'Add sub-item') as string}>
|
||||
<CornerDownRight className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
{onSaveAsPreset && !sub && (
|
||||
<button type="button" onClick={() => onSaveAsPreset(li)} aria-label="Save as preset"
|
||||
className="p-1 rounded hover:bg-neutral-100 dark:hover:bg-neutral-700"
|
||||
title={t('crm.lineItems.saveAsPreset', 'Save as preset') as string}>
|
||||
<SaveIcon className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<button type="button" onClick={() => removeRow(idx)} aria-label="Remove"
|
||||
className="p-1 rounded hover:bg-red-50 dark:hover:bg-red-900/30 text-red-600">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
{items.length === 0 && (
|
||||
<tr><td colSpan={showDiscount ? 7 : 6} className="px-2 py-6 text-center text-neutral-500">
|
||||
{t('crm.lineItems.empty', 'No line items yet — add one to get started.')}
|
||||
</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => addRow()}>
|
||||
<Plus className="w-4 h-4 mr-1" />{t('crm.lineItems.addRow', 'Add row')}
|
||||
</Button>
|
||||
{presets.length > 0 && (
|
||||
<select
|
||||
className="text-sm rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 px-3 py-1.5"
|
||||
onChange={(e) => {
|
||||
const id = parseInt(e.target.value, 10);
|
||||
const preset = presets.find((p) => p.id === id);
|
||||
if (preset) addRow(preset);
|
||||
e.target.value = '';
|
||||
}}
|
||||
defaultValue=""
|
||||
>
|
||||
<option value="" disabled>{t('crm.lineItems.addFromPreset', 'Add from preset…')}</option>
|
||||
{presets.map((p) => (
|
||||
<option key={p.id} value={p.id}>{p.name}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-end gap-1 text-sm pt-2 border-t border-neutral-200 dark:border-neutral-700">
|
||||
<div className="flex gap-6"><span className="text-neutral-600 dark:text-neutral-400">{t('crm.lineItems.subtotal', 'Subtotal')}:</span><span className="tabular-nums w-28 text-right">{formatMoney(subtotal, currency)}</span></div>
|
||||
<div className="flex gap-6"><span className="text-neutral-600 dark:text-neutral-400">{t('crm.lineItems.vat', 'VAT')} ({(vatRate * 100).toFixed(1)}%):</span><span className="tabular-nums w-28 text-right">{formatMoney(vatAmount, currency)}</span></div>
|
||||
{!!shippingAmount && (
|
||||
<div className="flex gap-6"><span className="text-neutral-600 dark:text-neutral-400">{t('crm.lineItems.shipping', 'Shipping')}:</span><span className="tabular-nums w-28 text-right">{formatMoney(shippingAmount, currency)}</span></div>
|
||||
)}
|
||||
<div className="flex gap-6 font-semibold text-base"><span>{t('crm.lineItems.total', 'Total')}:</span><span className="tabular-nums w-28 text-right">{formatMoney(total, currency)}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// `formatMoney` is now the canonical helper from utils/money. Re-exported
|
||||
// here so call-sites that historically imported from this file
|
||||
// (CustomerCrmPanels, page-level summaries) keep working without churn.
|
||||
export { formatMoney };
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Settings → Branding card: lets the admin pick the font used on every
|
||||
* PDF (quotes, invoices, tax report) from the bundled families. Sits
|
||||
* directly beneath the web Typography customizer card on
|
||||
* `BrandingPage`, inside the left column — matches the typography
|
||||
* box width so the two visually pair.
|
||||
*
|
||||
* Controlled component. State + persistence live on the parent so the
|
||||
* page's top-level "Save changes" button writes this together with
|
||||
* the rest of the branding form (no card-local save button).
|
||||
*
|
||||
* Loads the same `/public/fonts` list the web font picker consumes,
|
||||
* so any family bundled in `backend/assets/fonts/` shows up here too
|
||||
* automatically. The selected value persists to
|
||||
* `business_profile.pdf_font_family` and pdfService maps it to
|
||||
* `<family>/400.ttf` (body) + `<family>/700.ttf` (bold) at render
|
||||
* time.
|
||||
*
|
||||
* Hidden by the caller when no PDF-producing feature is enabled
|
||||
* (quotes / bills / taxReport all off) — when there's no PDF surface
|
||||
* the setting is irrelevant.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Type } from 'lucide-react';
|
||||
import { Card } from '../common';
|
||||
import { fontsService } from '../../services/fonts.service';
|
||||
|
||||
/**
|
||||
* The bundled-fonts API returns the DISPLAY name (e.g. "Playfair
|
||||
* Display") but pdfService resolves a DIRECTORY name (e.g.
|
||||
* "Playfair-Display"). They're always related by space ↔ hyphen.
|
||||
* The helpers below convert between them so the dropdown can show
|
||||
* a clean human label while persisting the on-disk identifier.
|
||||
*/
|
||||
const familyToDirectory = (family: string) => family.replace(/ /g, '-');
|
||||
const directoryToFamily = (dir: string) => dir.replace(/-/g, ' ');
|
||||
|
||||
export interface PdfTypographyCardProps {
|
||||
/** Directory name (e.g. "Inter", "Playfair-Display") or null/""
|
||||
* for "Use Helvetica (default)". */
|
||||
value: string | null;
|
||||
onChange: (value: string | null) => void;
|
||||
}
|
||||
|
||||
export const PdfTypographyCard: React.FC<PdfTypographyCardProps> = ({ value, onChange }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Same query the web typography picker consumes — single source of
|
||||
// truth for "which bundled families exist on disk".
|
||||
const { data: availableFonts } = useQuery({
|
||||
queryKey: ['fonts'],
|
||||
queryFn: () => fontsService.list(),
|
||||
staleTime: 60 * 60 * 1000, // fonts don't change at runtime
|
||||
});
|
||||
|
||||
const selection = value || '';
|
||||
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-1 flex items-center gap-2">
|
||||
<Type className="w-5 h-5" />
|
||||
{t('branding.pdfTypography', 'PDF typography')}
|
||||
</h3>
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4">
|
||||
{t('branding.pdfTypographyHelp',
|
||||
'Used for invoice + quote letterheads. Pick one of the bundled fonts, or leave on default to use Helvetica.')}
|
||||
</p>
|
||||
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
|
||||
{t('branding.pdfFontFamily', 'Body font')}
|
||||
</label>
|
||||
<select
|
||||
value={selection}
|
||||
onChange={(e) => onChange(e.target.value ? e.target.value : null)}
|
||||
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100"
|
||||
>
|
||||
<option value="">
|
||||
{t('branding.pdfFontFamilyDefault', 'Use Helvetica (default)')}
|
||||
</option>
|
||||
{(availableFonts || []).map((f) => (
|
||||
<option key={f.family} value={familyToDirectory(f.family)}>
|
||||
{/* Show the display name (with spaces) — but persist
|
||||
the directory name (with hyphens) so pdfService can
|
||||
find the on-disk family without an extra lookup. */}
|
||||
{f.family}
|
||||
</option>
|
||||
))}
|
||||
{/* When the saved value points at a family that's no
|
||||
longer on disk (e.g. uploaded by an earlier admin,
|
||||
later removed), still show it so the admin sees what
|
||||
they have rather than silently re-mapping to default. */}
|
||||
{selection && !(availableFonts || []).some((f) => familyToDirectory(f.family) === selection) && (
|
||||
<option value={selection}>
|
||||
{directoryToFamily(selection)} ({t('branding.pdfFontFamilyMissing', 'missing')})
|
||||
</option>
|
||||
)}
|
||||
</select>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -66,6 +66,12 @@ interface ThemeCustomizerEnhancedProps {
|
||||
// only the colour tokens swap, layout/header/typography stay put so an
|
||||
// admin who's already arranged the structure can pull just the palette.
|
||||
onSyncFromBranding?: () => void;
|
||||
// Optional render slot inserted between the Typography & Style /
|
||||
// CSS Templates section and the Event-specific Custom CSS card.
|
||||
// Used by BrandingPage to slot in unrelated cards (PDF typography)
|
||||
// so they live with the other typography choices rather than after
|
||||
// the always-bulky Custom CSS editor.
|
||||
slotBeforeCustomCss?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -174,7 +180,8 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
onCssTemplateChange,
|
||||
forceColorMode,
|
||||
onForceColorModeChange,
|
||||
onSyncFromBranding
|
||||
onSyncFromBranding,
|
||||
slotBeforeCustomCss
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [localTheme, setLocalTheme] = useState<ThemeConfig>(value);
|
||||
@@ -1364,6 +1371,11 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Caller-provided slot — used by BrandingPage to keep the
|
||||
PDF typography card adjacent to the web typography section
|
||||
instead of trailing the (often-collapsed) Custom CSS block. */}
|
||||
{slotBeforeCustomCss}
|
||||
|
||||
{/* Event-specific Custom CSS */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4 flex items-center gap-2">
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { parseLocaleDecimal } from '../../utils/parsers';
|
||||
|
||||
/**
|
||||
* DecimalInput — controlled text input that accepts both '.' and ','
|
||||
* as the decimal separator. Bubbles a parsed `number` up via
|
||||
* `onChange`, but keeps the raw typed string in local state so the
|
||||
* user can see their own keystrokes (German "12,5" stays "12,5"
|
||||
* during editing instead of being instantly reformatted to "12.5").
|
||||
*
|
||||
* Why not `type="number"`? Chrome/Firefox `<input type=number>` reject
|
||||
* comma input on non-DE document.lang and round-trip everything
|
||||
* through `parseFloat`, which silently truncates "12,50" to 12. The
|
||||
* audit found this eating real money on monthly bills typed by the
|
||||
* operator in German. Switching to `type="text"` plus
|
||||
* `inputMode="decimal"` keeps the mobile keypad and re-routes parsing
|
||||
* through the locale-tolerant helper.
|
||||
*
|
||||
* Value-prop sync: the parent owns the canonical number. When the
|
||||
* parent pushes a value that disagrees with what's in our local text
|
||||
* (after parsing the local text back to a number), we reformat. This
|
||||
* lets external updates (preset selection, autocompute) reflect in
|
||||
* the field while still preserving the user's in-progress typing.
|
||||
*/
|
||||
export interface DecimalInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'value' | 'onChange' | 'type' | 'inputMode'> {
|
||||
/** Canonical numeric value. May be NaN when the field is empty. */
|
||||
value: number;
|
||||
/** Bubbled on every parseable keystroke. Receives NaN when the input is cleared. */
|
||||
onChange: (next: number) => void;
|
||||
/**
|
||||
* Number of fraction digits to display when reformatting from a
|
||||
* parent-pushed value. Defaults to undefined which uses the
|
||||
* platform default (`Number.prototype.toString`).
|
||||
*/
|
||||
fractionDigits?: number;
|
||||
/** Optional formatter override — receives the number, returns the display string. */
|
||||
formatValue?: (value: number) => string;
|
||||
}
|
||||
|
||||
function defaultFormat(value: number, fractionDigits?: number): string {
|
||||
if (!Number.isFinite(value)) return '';
|
||||
if (fractionDigits !== undefined) return value.toFixed(fractionDigits);
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export function DecimalInput({
|
||||
value,
|
||||
onChange,
|
||||
fractionDigits,
|
||||
formatValue,
|
||||
...rest
|
||||
}: DecimalInputProps) {
|
||||
const fmt = (v: number) => (formatValue ? formatValue(v) : defaultFormat(v, fractionDigits));
|
||||
const [text, setText] = useState<string>(() => fmt(value));
|
||||
const lastEmittedRef = useRef<number>(value);
|
||||
|
||||
// Sync from the parent when the externally-owned value disagrees
|
||||
// with our locally-buffered text. We compare on the parsed number,
|
||||
// not the string, so the user typing "12," doesn't snap back to
|
||||
// "12" just because the parent saw 12 from the last keystroke.
|
||||
useEffect(() => {
|
||||
const parsed = parseLocaleDecimal(text);
|
||||
const matchesParsed = Number.isFinite(parsed) && parsed === value;
|
||||
const bothEmpty = text === '' && !Number.isFinite(value);
|
||||
if (!matchesParsed && !bothEmpty && value !== lastEmittedRef.current) {
|
||||
setText(fmt(value));
|
||||
}
|
||||
lastEmittedRef.current = value;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
value={text}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value;
|
||||
setText(raw);
|
||||
// Empty field → propagate NaN so the parent can decide
|
||||
// whether to coerce to 0 or treat as "unset".
|
||||
if (raw.trim() === '') {
|
||||
lastEmittedRef.current = NaN;
|
||||
onChange(NaN);
|
||||
return;
|
||||
}
|
||||
const parsed = parseLocaleDecimal(raw);
|
||||
if (Number.isFinite(parsed)) {
|
||||
lastEmittedRef.current = parsed;
|
||||
onChange(parsed);
|
||||
}
|
||||
// Unparseable intermediate state (e.g. "12,") — keep the
|
||||
// text but don't emit. The next keystroke usually completes
|
||||
// the number and triggers a valid emit.
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
// Reformat on blur so the resting state shows the canonical
|
||||
// form. Empty stays empty.
|
||||
const parsed = parseLocaleDecimal(e.target.value);
|
||||
if (Number.isFinite(parsed)) {
|
||||
setText(fmt(parsed));
|
||||
}
|
||||
rest.onBlur?.(e);
|
||||
}}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -34,7 +34,7 @@ interface GalleryLayoutProps {
|
||||
logo_url?: string;
|
||||
logo_size?: 'small' | 'medium' | 'large' | 'xlarge' | 'custom';
|
||||
logo_max_height?: number;
|
||||
logo_position?: 'left' | 'center' | 'right';
|
||||
logo_position?: 'left' | 'center' | 'right' | 'sidepanel';
|
||||
logo_display_header?: boolean;
|
||||
logo_display_hero?: boolean;
|
||||
logo_display_mode?: 'logo_only' | 'text_only' | 'logo_and_text';
|
||||
@@ -174,9 +174,13 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
||||
};
|
||||
};
|
||||
|
||||
// Determine logo position classes
|
||||
// Determine logo position classes. 'sidepanel' is an admin-chrome
|
||||
// option (logo lives in the admin sidebar) and has no analogue in
|
||||
// the gallery — fall back to 'left' here so customer galleries keep
|
||||
// showing the logo somewhere sensible.
|
||||
const getLogoPositionClass = () => {
|
||||
const position = brandingSettings?.logo_position || 'left';
|
||||
const raw = brandingSettings?.logo_position || 'left';
|
||||
const position = raw === 'sidepanel' ? 'left' : raw;
|
||||
return {
|
||||
left: 'justify-start',
|
||||
center: 'justify-center',
|
||||
|
||||
Reference in New Issue
Block a user