From 285ee6d2dbefc60e11aa91a53dfe388377920280 Mon Sep 17 00:00:00 2001 From: seaznCode Date: Sun, 17 May 2026 13:34:25 +0200 Subject: [PATCH 1/2] remove: PlanSelectorCard --- .../components/PlanSelectorCard.tsx | 64 ------------------- 1 file changed, 64 deletions(-) delete mode 100644 src/app/coffee-abonnements/components/PlanSelectorCard.tsx diff --git a/src/app/coffee-abonnements/components/PlanSelectorCard.tsx b/src/app/coffee-abonnements/components/PlanSelectorCard.tsx deleted file mode 100644 index 451a31e..0000000 --- a/src/app/coffee-abonnements/components/PlanSelectorCard.tsx +++ /dev/null @@ -1,64 +0,0 @@ -type Props = { - selectedPlanCapsules: number; - shippingLoading: boolean; - isFreeShippingSelected: boolean; - selectedShippingFee: number; - shippingError: string | null; - onDecrease: () => void; - onIncrease: () => void; - loadingText: string; - freeShippingText: string; -}; - -export default function PlanSelectorCard({ - selectedPlanCapsules, - shippingLoading, - isFreeShippingSelected, - selectedShippingFee, - shippingError, - onDecrease, - onIncrease, - loadingText, - freeShippingText, -}: Props) { - return ( -
-
- -
-
{selectedPlanCapsules} pcs
-
{selectedPlanCapsules / 10} packs of 10 · min. 60
-
- -
- {shippingLoading ? ( - {loadingText} - ) : isFreeShippingSelected ? ( - {freeShippingText} - ) : ( - Shipping EUR {selectedShippingFee.toFixed(2)} - )} -
-
- - {shippingError && ( -
- Shipping fees could not be loaded: {shippingError} -
- )} -
- ); -} -- 2.39.5 From bcc953edc100289f36263563990a6bad596654a8 Mon Sep 17 00:00:00 2001 From: seaznCode Date: Sun, 17 May 2026 16:17:30 +0200 Subject: [PATCH 2/2] iwd --- .../components/companySettingsPanel.tsx | 386 +++++++++--------- .../components/contractTemplateList.tsx | 194 ++++++++- .../hooks/useContractManagement.ts | 37 +- src/app/admin/contract-management/page.tsx | 16 +- .../finance-management/hooks/getInvoices.ts | 60 +++ .../hooks/useFinanceManagementPageState.ts | 25 +- src/app/admin/finance-management/page.tsx | 8 +- .../components/modals/ConfirmActionModal.tsx | 2 +- src/app/i18n/translations/de.ts | 21 +- src/app/i18n/translations/en.ts | 21 +- 10 files changed, 507 insertions(+), 263 deletions(-) diff --git a/src/app/admin/contract-management/components/companySettingsPanel.tsx b/src/app/admin/contract-management/components/companySettingsPanel.tsx index fd0a2c9..6851ba8 100644 --- a/src/app/admin/contract-management/components/companySettingsPanel.tsx +++ b/src/app/admin/contract-management/components/companySettingsPanel.tsx @@ -1,71 +1,68 @@ 'use client' - - -import { useTranslation } from '../../../i18n/useTranslation'; -import { useState, useEffect } from 'react' +import { useTranslation } from '../../../i18n/useTranslation' +import { useEffect, useRef, useState } from 'react' import useContractManagement from '../hooks/useContractManagement' -function fileToDataUrl(file: File): Promise { - return new Promise((resolve, reject) => { +const LOGO_ACCEPTED_TYPES = ['image/png', 'image/jpeg', 'image/webp', 'image/svg+xml'] +const MAX_LOGO_BYTES = 1024 * 1024 + +type CompanySettingsForm = { + company_name: string + company_street: string + company_postal_city: string + company_country: string + company_logo_base64: string | null + company_logo_mime_type: string | null +} + +function fileToBase64Payload(file: File) { + return new Promise<{ base64: string; mimeType: string }>((resolve, reject) => { const reader = new FileReader() - reader.onerror = () => reject(new Error('Failed to read file')) reader.onload = () => { - const result = reader.result - if (typeof result === 'string') resolve(result) - else reject(new Error('Invalid file result')) + const result = typeof reader.result === 'string' ? reader.result : '' + const match = result.match(/^data:(image\/[a-zA-Z0-9.+-]+);base64,(.+)$/) + if (!match) { + reject(new Error('invalid_data_url')) + return + } + resolve({ mimeType: match[1], base64: match[2] }) } + reader.onerror = () => reject(new Error('read_failed')) reader.readAsDataURL(file) }) } -function summarizeForLog(payload: Record) { - const out: Record = {} - for (const [k, v] of Object.entries(payload)) { - if (typeof v === 'string' && (k.toLowerCase().includes('base64') || k.toLowerCase().includes('qr_code'))) { - out[k] = { kind: 'base64', len: v.length, head: v.slice(0, 32) } - } else if (typeof v === 'string' && v.length > 200) { - out[k] = { kind: 'string', len: v.length, head: v.slice(0, 32) } - } else { - out[k] = v - } - } - return out -} - export default function CompanySettingsPanel() { - const { t } = useTranslation(); + const { t } = useTranslation() const { getCompanySettings, updateCompanySettings } = useContractManagement() + const logoInputRef = useRef(null) - const [form, setForm] = useState({ + const [form, setForm] = useState({ company_name: '', company_street: '', company_postal_city: '', company_country: '', + company_logo_base64: null, + company_logo_mime_type: null, }) - const [hasQr60, setHasQr60] = useState(false) - const [hasQr120, setHasQr120] = useState(false) - const [qr60DataUrl, setQr60DataUrl] = useState('') - const [qr120DataUrl, setQr120DataUrl] = useState('') const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(false) const [saved, setSaved] = useState(false) const [saveError, setSaveError] = useState('') + const [logoError, setLogoError] = useState('') useEffect(() => { getCompanySettings() - .then(data => { + .then((data) => { setForm({ company_name: data.company_name || '', company_street: data.company_street || '', company_postal_city: data.company_postal_city || '', company_country: data.company_country || '', + company_logo_base64: data.company_logo_base64 || null, + company_logo_mime_type: data.company_logo_mime_type || null, }) - - const qr60 = (data as any)?.qr_code_60_base64 ?? (data as any)?.qrCode60Base64 - const qr120 = (data as any)?.qr_code_120_base64 ?? (data as any)?.qrCode120Base64 - setHasQr60(!!qr60) - setHasQr120(!!qr120) }) .catch(() => {}) .finally(() => setLoading(false)) @@ -73,194 +70,203 @@ export default function CompanySettingsPanel() { const handleChange = (e: React.ChangeEvent) => { const { name, value } = e.target - setForm(prev => ({ ...prev, [name]: value })) + setForm((prev) => ({ ...prev, [name]: value })) setSaved(false) } + const handleLogoChange = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + setLogoError('') + if (!file) return + + if (!LOGO_ACCEPTED_TYPES.includes(file.type)) { + setLogoError(t('autofix.k2bd38d5e')) + if (logoInputRef.current) logoInputRef.current.value = '' + return + } + + if (file.size > MAX_LOGO_BYTES) { + setLogoError(t('autofix.k394b7f42')) + if (logoInputRef.current) logoInputRef.current.value = '' + return + } + + try { + const { base64, mimeType } = await fileToBase64Payload(file) + setForm((prev) => ({ + ...prev, + company_logo_base64: base64, + company_logo_mime_type: mimeType, + })) + setSaved(false) + } catch { + setLogoError(t('autofix.k8a1d4c20')) + } finally { + if (logoInputRef.current) logoInputRef.current.value = '' + } + } + + const handleRemoveLogo = () => { + setForm((prev) => ({ + ...prev, + company_logo_base64: null, + company_logo_mime_type: null, + })) + setLogoError('') + setSaved(false) + if (logoInputRef.current) logoInputRef.current.value = '' + } + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() setSaving(true) setSaved(false) setSaveError('') try { - // IMPORTANT: send `payload` (full strings), not the redacted log view. - const payload: any = { ...form } - if (qr60DataUrl) payload.qr_code_60_base64 = qr60DataUrl - if (qr120DataUrl) payload.qr_code_120_base64 = qr120DataUrl - - // For logging only (redacted); never send this object. - const logPayload: any = summarizeForLog(payload) - - try { - const qr60 = payload.qr_code_60_base64 - const qr120 = payload.qr_code_120_base64 - console.info('[CompanySettingsPanel] updateCompanySettings payload', { - logPayload, - keys: Object.keys(payload), - jsonLength: JSON.stringify(payload).length, - qrFieldTypes: { - qr_code_60_base64: qr60 ? typeof qr60 : null, - qr_code_120_base64: qr120 ? typeof qr120 : null, - }, - qrFieldLengths: { - qr_code_60_base64: typeof qr60 === 'string' ? qr60.length : null, - qr_code_120_base64: typeof qr120 === 'string' ? qr120.length : null, - }, - }) - - if (qr60 && typeof qr60 !== 'string') console.warn('[CompanySettingsPanel] qr_code_60_base64 is not a string!', qr60) - if (qr120 && typeof qr120 !== 'string') console.warn('[CompanySettingsPanel] qr_code_120_base64 is not a string!', qr120) - } catch {} - - await updateCompanySettings(payload) + await updateCompanySettings(form) setSaved(true) setTimeout(() => setSaved(false), 3000) } catch { - setSaveError('Could not save settings.') + setSaveError(t('autofix.k95a16b2b')) } finally { setSaving(false) } } - const handleQrUpload = async (which: '60' | '120', file: File | null) => { - setSaved(false) - setSaveError('') - if (!file) return - - // Backend accepts 10MB JSON, but base64 expands the payload. - // Keep a conservative limit to avoid 413 Payload Too Large. - const MAX_FILE_BYTES = 7_000_000 - if (file.size > MAX_FILE_BYTES) { - setSaveError('QR image is too large. Please upload a smaller PNG.') - return - } - if (file.type && file.type !== 'image/png') { - setSaveError('Please upload a PNG file for the QR code.') - return - } - - try { - const dataUrl = await fileToDataUrl(file) - // Normalize to raw base64, to match other endpoints (e.g. company stamp upload) - const m = dataUrl.match(/^data:(.+?);base64,(.*)$/) - const base64 = m ? m[2] : dataUrl - if (which === '60') { - setQr60DataUrl(base64) - setHasQr60(true) - } else { - setQr120DataUrl(base64) - setHasQr120(true) - } - } catch { - setSaveError('Could not read QR image file.') - } - } + const logoPreviewSrc = form.company_logo_base64 + ? `data:${form.company_logo_mime_type || 'image/png'};base64,${form.company_logo_base64}` + : null if (loading) { return ( -
-
{t('autofix.k81a1b900')}
+
+
+ {t('autofix.k81a1b900')} +
) } return ( -
-
-
- - + +
+
+
+

{t('autofix.k0198ce13')}

+

{t('autofix.k03d7361d')}

+

{t('autofix.k1c2b0975')}

+
+
+ + + {logoPreviewSrc && ( + + )} +
-
- - + +
+ {logoPreviewSrc ? ( + {t('autofix.k0198ce13')} + ) : ( +
{t('autofix.k432b8a12')}
+ )}
-
- - -
-
- - + + {logoError &&
{logoError}
} +
+ +
+

+ + {t('autofix.kaa8bbc8e')} +

+

{t('autofix.k15bea9bb')}

+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
-
-
- - handleQrUpload('60', e.target.files?.[0] || null)} - className="block w-full text-sm text-gray-700 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-900 hover:file:bg-blue-100" - /> -
{qr60DataUrl ? 'Selected (will be saved on Save)' : hasQr60 ? t('autofix.k0422a021') : t('autofix.k867bfd52')}
-
- -
- - handleQrUpload('120', e.target.files?.[0] || null)} - className="block w-full text-sm text-gray-700 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-900 hover:file:bg-blue-100" - /> -
{qr120DataUrl ? 'Selected (will be saved on Save)' : hasQr120 ? t('autofix.k0422a021') : t('autofix.k867bfd52')}
-
-
- - {saveError && ( -
{saveError}
- )} + {saveError &&
{saveError}
}
- {saved && ( - {t('autofix.ka29ac729')} - )} + className={`rounded-lg px-5 py-2 text-sm font-semibold text-white transition-colors ${saving ? 'cursor-not-allowed bg-gray-400' : 'bg-blue-900 hover:bg-blue-800'}`} + > + {saving ? t('autofix.kac6cedc7') : 'Save'} + + {saved && {t('autofix.ka29ac729')}}
) diff --git a/src/app/admin/contract-management/components/contractTemplateList.tsx b/src/app/admin/contract-management/components/contractTemplateList.tsx index ba9cb91..b726f64 100644 --- a/src/app/admin/contract-management/components/contractTemplateList.tsx +++ b/src/app/admin/contract-management/components/contractTemplateList.tsx @@ -1,6 +1,8 @@ 'use client'; import React, { useEffect, useMemo, useState } from 'react'; +import { Dialog, Transition } from '@headlessui/react'; +import { XMarkIcon } from '@heroicons/react/24/outline'; import useContractManagement, { DocumentTemplate } from '../hooks/useContractManagement'; import ConfirmActionModal from '../../../components/modals/ConfirmActionModal'; @@ -75,6 +77,12 @@ type ContractTypeSection = { activeTemplates: number; }; +type VersionHistoryModalState = { + trackTitle: string; + languageLabel: string; + templates: ContractTemplate[]; +}; + type NormalizedTemplate = DocumentTemplate & { _id?: string; uuid?: string; @@ -346,6 +354,143 @@ function StatusBadge({ status }: { status: string }) { return {labels[status] || status}; } +function TemplateVersionHistoryModal({ + history, + open, + onClose, + onEdit, + onPreview, + onGenPdf, + onDownloadPdf, + onToggleState, +}: { + history: VersionHistoryModalState | null; + open: boolean; + onClose: () => void; + onEdit?: (id: string) => void; + onPreview: (id: string) => void; + onGenPdf: (id: string) => void; + onDownloadPdf: (id: string) => void; + onToggleState: (id: string, current: string) => Promise; +}) { + const templates = history?.templates || []; + + return ( + + + +
+ + +
+
+ + +
+
+ Version history +

+ {history ? `${history.trackTitle} • ${history.languageLabel}` : ''} +

+

+ Hidden revisions stay out of the main list, but remain available for preview, editing and re-activation here. +

+
+ + +
+ +
+ {templates.map((template) => ( +
+
+
+
+
v{template.version}
+ +
+

{template.name}

+
{formatTimestamp(template.updatedAt) || 'n/a'}
+
+ +
+ {onEdit && ( + + )} + + + + +
+
+
+ ))} + + {!templates.length && ( +
+ No hidden versions for this language. +
+ )} +
+
+
+
+
+
+
+ ); +} + export default function ContractTemplateList({ refreshKey = 0, onEdit }: Props) { const { t } = useTranslation(); const [items, setItems] = useState([]); @@ -354,6 +499,7 @@ export default function ContractTemplateList({ refreshKey = 0, onEdit }: Props) const [selectedFamily, setSelectedFamily] = useState('contract'); const [selectedLanguageByTrack, setSelectedLanguageByTrack] = useState>({}); const [pendingToggle, setPendingToggle] = useState<{ id: string; target: 'active' | 'inactive'; message?: string; requiresConfirm: boolean } | null>(null); + const [versionHistory, setVersionHistory] = useState(null); const { listTemplates, @@ -533,7 +679,12 @@ export default function ContractTemplateList({ refreshKey = 0, onEdit }: Props) const confirmToggleState = async () => { if (!pendingToggle) return; + const shouldCloseVersionHistory = pendingToggle.target === 'active' + && Boolean(versionHistory?.templates.some((template) => template.id === pendingToggle.id)); await executeToggleState(pendingToggle.id, pendingToggle.target); + if (shouldCloseVersionHistory) { + setVersionHistory(null); + } setPendingToggle(null); }; @@ -561,6 +712,13 @@ export default function ContractTemplateList({ refreshKey = 0, onEdit }: Props) if (!visibleLanguageColumn) return null; + const primaryTemplate = visibleLanguageColumn.activeTemplate || visibleLanguageColumn.templates[0] || null; + const visibleTemplates = primaryTemplate ? [primaryTemplate] : []; + const hiddenTemplates = primaryTemplate + ? visibleLanguageColumn.templates.filter((template) => template.id !== primaryTemplate.id) + : visibleLanguageColumn.templates; + const hiddenVersionCount = hiddenTemplates.length; + return (
{visibleLanguageColumn.activeTemplate ? `Active version: v${visibleLanguageColumn.activeTemplate.version}` - : 'No active version yet'} + : primaryTemplate + ? `Latest version: v${primaryTemplate.version}` + : 'No version yet'}
-
- {visibleLanguageColumn.templates.length} version{visibleLanguageColumn.templates.length === 1 ? '' : 's'} +
+
+ {visibleLanguageColumn.templates.length} version{visibleLanguageColumn.templates.length === 1 ? '' : 's'} +
+ {hiddenVersionCount > 0 && ( + + )}
- {visibleLanguageColumn.templates.map((template) => ( + {visibleTemplates.map((template) => (
@@ -851,6 +1026,17 @@ export default function ContractTemplateList({ refreshKey = 0, onEdit }: Props) onClose={() => setPendingToggle(null)} onConfirm={confirmToggleState} /> + + setVersionHistory(null)} + onEdit={onEdit} + onPreview={onPreview} + onGenPdf={onGenPdf} + onDownloadPdf={onDownloadPdf} + onToggleState={onToggleState} + />
); } diff --git a/src/app/admin/contract-management/hooks/useContractManagement.ts b/src/app/admin/contract-management/hooks/useContractManagement.ts index ff3a10e..687a919 100644 --- a/src/app/admin/contract-management/hooks/useContractManagement.ts +++ b/src/app/admin/contract-management/hooks/useContractManagement.ts @@ -545,12 +545,8 @@ export default function useContractManagement() { company_street?: string company_postal_city?: string company_country?: string - // NEW: QR codes for invoices (base64 or data URL) - qr_code_60_base64?: string | null - qr_code_120_base64?: string | null - // NEW: allow camelCase too (backend supports both) - qrCode60Base64?: string | null - qrCode120Base64?: string | null + company_logo_base64?: string | null + company_logo_mime_type?: string | null } const getCompanySettings = useCallback(async () => { @@ -558,35 +554,6 @@ export default function useContractManagement() { }, [authorizedFetch]); const updateCompanySettings = useCallback(async (data: Partial) => { - // Debug request body in browser console (redacts base64 values) - try { - // IMPORTANT: `data` is the real payload object; `redacted` is for logs only. - const json = JSON.stringify(data); - const redacted = redactJsonForLogs(data); - const qr60 = (data as any)?.qr_code_60_base64 ?? (data as any)?.qrCode60Base64; - const qr120 = (data as any)?.qr_code_120_base64 ?? (data as any)?.qrCode120Base64; - console.info('[CM][company-settings] PUT body', { - redacted, - jsonLength: json.length, - keys: Object.keys(data || {}), - qrFieldTypes: { - qr_code_60_base64: qr60 ? typeof qr60 : null, - qr_code_120_base64: qr120 ? typeof qr120 : null, - }, - qrFieldLengths: { - qr_code_60_base64: typeof qr60 === 'string' ? qr60.length : null, - qr_code_120_base64: typeof qr120 === 'string' ? qr120.length : null, - }, - }); - - if (qr60 && typeof qr60 !== 'string') { - console.warn('[CM][company-settings] qr_code_60_base64 is not a string!', qr60); - } - if (qr120 && typeof qr120 !== 'string') { - console.warn('[CM][company-settings] qr_code_120_base64 is not a string!', qr120); - } - } catch {} - return authorizedFetch('/api/admin/company-settings', { method: 'PUT', body: JSON.stringify(data), diff --git a/src/app/admin/contract-management/page.tsx b/src/app/admin/contract-management/page.tsx index 4ee81de..84d36bb 100644 --- a/src/app/admin/contract-management/page.tsx +++ b/src/app/admin/contract-management/page.tsx @@ -13,7 +13,7 @@ import { useRouter } from 'next/navigation'; import { useTranslation } from '../../i18n/useTranslation'; const NAV = [ - { key: 'stamp', label: 'Company Stamp', icon: }, + { key: 'stamp', label: 'Company Details', icon: }, { key: 'mailTemplates', label: 'Mail Templates', icon: }, { key: 'templates', label: 'Templates', icon: }, { key: 'editor', label: 'Create Template', icon: }, @@ -97,11 +97,6 @@ export default function ContractManagementPage() { {t('autofix.k39791457')}

-
- {t('autofix.k61d66984')} - {t('autofix.k74823841')} - {t('autofix.kccff045c')} -
@@ -111,15 +106,14 @@ export default function ContractManagementPage() { {t('autofix.ka5f38d19')} - +

- - {t('autofix.kaa8bbc8e')} + + {t('autofix.k54d7cbef')}

-

{t('autofix.k15bea9bb')}

- +
)} diff --git a/src/app/admin/finance-management/hooks/getInvoices.ts b/src/app/admin/finance-management/hooks/getInvoices.ts index ccdc95a..25880e7 100644 --- a/src/app/admin/finance-management/hooks/getInvoices.ts +++ b/src/app/admin/finance-management/hooks/getInvoices.ts @@ -27,6 +27,12 @@ export type AdminInvoice = { updated_at?: string | null; }; +export type AdminInvoiceRevenueSummary = { + totalPaidAllTime: number; + currency?: string | null; + paidInvoiceCount?: number; +}; + export function useAdminInvoices(params?: { status?: string; limit?: number; offset?: number }) { const accessToken = useAuthStore(s => s.accessToken); const [invoices, setInvoices] = useState([]); @@ -91,3 +97,57 @@ export function useAdminInvoices(params?: { status?: string; limit?: number; off return { invoices, loading, error, reload: fetchInvoices }; } + +export function useAdminInvoiceRevenueSummary() { + const accessToken = useAuthStore(s => s.accessToken); + const [summary, setSummary] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const inFlight = useRef(null); + + const fetchSummary = useCallback(async () => { + setError(''); + inFlight.current?.abort(); + const controller = new AbortController(); + inFlight.current = controller; + + try { + const base = process.env.NEXT_PUBLIC_API_BASE_URL || ''; + const url = `${base}/api/admin/invoices/revenue-summary`; + + setLoading(true); + const res = await fetch(url, { + method: 'GET', + credentials: 'include', + headers: { + Accept: 'application/json', + ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}), + }, + signal: controller.signal, + }); + + const body = await res.json().catch(() => ({})); + if (!res.ok || body?.success === false) { + setSummary(null); + setError(body?.message || `Failed to load revenue summary (${res.status})`); + return; + } + + setSummary(body?.data || { totalPaidAllTime: 0, currency: 'EUR', paidInvoiceCount: 0 }); + } catch (e: any) { + if (e?.name === 'AbortError') return; + setError(e?.message || 'Network error'); + setSummary(null); + } finally { + setLoading(false); + if (inFlight.current === controller) inFlight.current = null; + } + }, [accessToken]); + + useEffect(() => { + if (accessToken) fetchSummary(); + return () => inFlight.current?.abort(); + }, [accessToken, fetchSummary]); + + return { summary, loading, error, reload: fetchSummary }; +} diff --git a/src/app/admin/finance-management/hooks/useFinanceManagementPageState.ts b/src/app/admin/finance-management/hooks/useFinanceManagementPageState.ts index f333555..e1fb5d7 100644 --- a/src/app/admin/finance-management/hooks/useFinanceManagementPageState.ts +++ b/src/app/admin/finance-management/hooks/useFinanceManagementPageState.ts @@ -1,7 +1,7 @@ -import { useMemo, useState } from 'react' +import { useCallback, useMemo, useState } from 'react' import { useRouter } from 'next/navigation' import { useVatRates } from './getTaxes' -import { useAdminInvoices, type AdminInvoice } from './getInvoices' +import { useAdminInvoiceRevenueSummary, useAdminInvoices, type AdminInvoice } from './getInvoices' import useAuthStore from '../../../store/authStore' import { useTranslation } from '../../../i18n/useTranslation' @@ -60,6 +60,17 @@ export function useFinanceManagementPageState() { limit: 200, offset: 0, }) + const { + summary: revenueSummary, + loading: revenueLoading, + error: revenueError, + reload: reloadRevenueSummary, + } = useAdminInvoiceRevenueSummary() + + const combinedInvoiceError = invError || (revenueError ? `Revenue summary: ${revenueError}` : '') + const reloadFinanceData = useCallback(async () => { + await Promise.all([reload(), reloadRevenueSummary()]) + }, [reload, reloadRevenueSummary]) const totals = useMemo(() => { const now = new Date() @@ -79,10 +90,10 @@ export function useFinanceManagementPageState() { }) return { - totalAll: invoices.reduce((sum, invoice) => sum + Number(invoice.total_gross ?? 0), 0), + totalAll: Number(revenueSummary?.totalPaidAllTime ?? 0), totalRange: range.reduce((sum, invoice) => sum + Number(invoice.total_gross ?? 0), 0), } - }, [invoices, timeframe]) + }, [invoices, revenueSummary, timeframe]) const filteredBills = useMemo(() => { const query = billFilter.query.trim().toLowerCase() @@ -323,9 +334,9 @@ export function useFinanceManagementPageState() { reportMsg, setReportMsg, invoices, - invLoading, - invError, - reload, + invLoading: invLoading || revenueLoading, + invError: combinedInvoiceError, + reload: reloadFinanceData, totals, filteredBills, exportBills, diff --git a/src/app/admin/finance-management/page.tsx b/src/app/admin/finance-management/page.tsx index 20aea24..c389763 100644 --- a/src/app/admin/finance-management/page.tsx +++ b/src/app/admin/finance-management/page.tsx @@ -333,12 +333,14 @@ export default function FinanceManagementPage() { if (!invoice.due_at) return const due = new Date(invoice.due_at) - const now = new Date() - const diffDays = Math.ceil((due.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)) + const today = new Date() + due.setHours(0, 0, 0, 0) + today.setHours(0, 0, 0, 0) + const diffDays = Math.round((due.getTime() - today.getTime()) / (1000 * 60 * 60 * 24)) let cls = 'bg-green-100 text-green-700' if (invoice.status === 'paid') cls = 'bg-green-100 text-green-700' - else if (diffDays < 0) cls = 'bg-red-100 text-red-700' + else if (invoice.status === 'overdue' || diffDays < 0) cls = 'bg-red-100 text-red-700' else if (diffDays <= 3) cls = 'bg-red-100 text-red-700' else if (diffDays <= 7) cls = 'bg-amber-100 text-amber-700' diff --git a/src/app/components/modals/ConfirmActionModal.tsx b/src/app/components/modals/ConfirmActionModal.tsx index 3523775..47de9d3 100644 --- a/src/app/components/modals/ConfirmActionModal.tsx +++ b/src/app/components/modals/ConfirmActionModal.tsx @@ -64,7 +64,7 @@ export default function ConfirmActionModal({ return ( - {} : onClose} className="relative z-[1100]"> + {} : onClose} className="relative z-1300">