From 38c5bbb62e96ec6e118a6b6d6c300759b3709d06 Mon Sep 17 00:00:00 2001 From: behrinml Date: Mon, 18 May 2026 17:07:17 +0200 Subject: [PATCH] feat(settings): Frontend changes to support billing Settings: - Introduced billing enabled toggle with printer kill switch functionality in the settings. - Added finance monthly budget reset day and timezone settings. Other: - Added cost center fields to PrintModal (if enabled) - Switched sequence in PrintModal (cost center after estimated cost, and before print options) - Implemented Finance Page --- frontend/src/App.tsx | 2 + frontend/src/api/client.ts | 219 ++- frontend/src/components/Layout.tsx | 4 +- .../PrintModal/CostCenterSelect.tsx | 38 + .../components/PrintModal/FilamentMapping.tsx | 23 +- frontend/src/components/PrintModal/index.tsx | 77 +- frontend/src/components/PrintModal/types.ts | 3 + frontend/src/pages/FinancePage.tsx | 1465 +++++++++++++++++ frontend/src/pages/SettingsPage.tsx | 156 ++ 9 files changed, 1960 insertions(+), 27 deletions(-) create mode 100644 frontend/src/components/PrintModal/CostCenterSelect.tsx create mode 100644 frontend/src/pages/FinancePage.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6fe5f2375..346f7782a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -7,6 +7,7 @@ import { ArchivesPage } from './pages/ArchivesPage'; import { QueuePage } from './pages/QueuePage'; import { StatsPage } from './pages/StatsPage'; import { SettingsPage } from './pages/SettingsPage'; +import { FinancePage } from './pages/FinancePage'; import { ProfilesPage } from './pages/ProfilesPage'; import { MaintenancePage } from './pages/MaintenancePage'; import { ProjectsPage } from './pages/ProjectsPage'; @@ -199,6 +200,7 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 791192f0a..8ecd2fe8f 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1185,6 +1185,11 @@ export interface AppSettings { // Staggered batch start defaults stagger_group_size: number; stagger_interval_minutes: number; + // Finance budget reset window + billing_enabled: boolean; + printer_kill_switch_enabled: boolean; + finance_budget_reset_day: number; + finance_budget_reset_timezone: string; // Plate-clear confirmation require_plate_clear: boolean; // Shortest job first scheduling @@ -1897,6 +1902,8 @@ export interface PrintQueueItem { // Either archive_id OR library_file_id must be set (archive created at print start) archive_id: number | null; library_file_id: number | null; + cost_center_id: number | null; + estimated_cost: number | null; position: number; scheduled_time: string | null; require_previous_success: boolean; @@ -2002,16 +2009,8 @@ export interface PrintQueueItemCreate { batch_id?: number | null; // Project to associate the resulting archive with project_id?: number; -} - -export interface PrintBatchCreate { - name: string; - archive_id?: number | null; - library_file_id?: number | null; - /** When set, the listed pending items are assigned to the new batch - * (manual "Group as batch"). When omitted/empty, an empty batch is - * returned so the client can pass batch_id on subsequent addToQueue calls. */ - item_ids?: number[]; + cost_center_id?: number | null; + estimated_cost?: number | null; } export interface PrintQueueItemUpdate { @@ -2036,6 +2035,8 @@ export interface PrintQueueItemUpdate { nozzle_offset_cali?: boolean; // Auto-print G-code injection gcode_injection?: boolean; + cost_center_id?: number | null; + estimated_cost?: number | null; } export interface PrintQueueBulkUpdate { @@ -2055,6 +2056,7 @@ export interface PrintQueueBulkUpdate { nozzle_offset_cali?: boolean; // Auto-print G-code injection gcode_injection?: boolean; + cost_center_id?: number | null; } export interface PrintQueueBulkUpdateResponse { @@ -2948,6 +2950,114 @@ export interface ExternalLinkUpdate { open_in_new_tab?: boolean; } +// Finance types +export interface CostCenterSummary { + id: number; + name: string; + is_private: boolean; + owner_user_id: number | null; + is_active: boolean; + total_balance: number; + total_budget: number | null; + monthly_budget: number | null; + budget_mode: 'none' | 'total' | 'monthly'; + budget_limit: number | null; + budget_used: number | null; + budget_available: number | null; + can_print: boolean; +} + +export interface CostCenterCreateRequest { + name: string; + total_budget?: number | null; + monthly_budget?: number | null; + is_active?: boolean; +} + +export interface CostCenterBudgetUpdateRequest { + total_budget?: number | null; + monthly_budget?: number | null; +} + +export interface CostCenterUpdateRequest { + name?: string; + is_active?: boolean; +} + +export interface CostCenterMemberRequest { + user_id: number; + can_print?: boolean; +} + +export interface CostCenterMemberResponse { + id: number; + cost_center_id: number; + user_id: number; + can_print: boolean; + created_at: string; +} + +export interface CostCenterDetail extends CostCenterSummary { + members: CostCenterMemberResponse[]; +} + +export interface WalletBalance { + user_id: number; + balance: number; + currency: string; + updated_at: string | null; +} + +export type WalletTransactionType = 'print_charge' | 'deposit' | 'withdraw' | 'manual_adjustment'; + +export interface WalletTransaction { + id: number; + user_id: number; + cost_center_id: number | null; + transaction_type: WalletTransactionType; + amount: number; + balance_after: number | null; + description: string | null; + created_by_user_id: number | null; + print_run_id: string | null; + print_archive_id: number | null; + print_queue_id: number | null; + created_at: string; +} + +export interface WalletTransactionListResponse { + items: WalletTransaction[]; + total: number; + limit: number; + offset: number; +} + +export interface WalletAdjustmentRequest { + amount: number; + description?: string; + cost_center_id?: number | null; +} + +export interface WalletAdjustmentResponse { + transaction: WalletTransaction; + balance: WalletBalance; +} + +export interface TransactionEditRequest { + user_id?: number | null; + cost_center_id?: number | null; + amount?: number | null; + description?: string | null; +} + +export interface ManualPrintRequest { + user_id: number; + cost_center_id: number; + amount: number; + description?: string | null; + created_at?: string | null; +} + // Permission type - all available permissions export type Permission = | 'printers:read' | 'printers:create' | 'printers:update' | 'printers:delete' | 'printers:control' | 'printers:files' | 'printers:ams_rfid' | 'printers:clear_plate' @@ -2974,6 +3084,7 @@ export type Permission = | 'discovery:scan' | 'firmware:read' | 'firmware:update' | 'ams_history:read' + | 'cost_centers:read_own' | 'cost_centers:read_all' | 'cost_centers:modify' | 'cost_centers:create' | 'stats:read' | 'stats:filter_by_user' | 'system:read' | 'settings:read' | 'settings:update' | 'settings:backup' | 'settings:restore' @@ -4373,6 +4484,8 @@ export const api = { vibration_cali?: boolean; layer_inspect?: boolean; use_ams?: boolean; + cost_center_id?: number; + estimated_cost?: number; nozzle_offset_cali?: boolean; } ) => @@ -4784,16 +4897,6 @@ export const api = { getBatch: (id: number) => request(`/queue/batches/${id}`), cancelBatch: (id: number) => request<{ message: string }>(`/queue/batches/${id}`, { method: 'DELETE' }), - createBatch: (data: PrintBatchCreate) => - request('/queue/batches', { - method: 'POST', - body: JSON.stringify(data), - }), - ungroupBatch: (id: number) => - request<{ ungrouped_count: number; message: string }>( - `/queue/batches/${id}/ungroup`, - { method: 'POST' }, - ), // K-Profiles getKProfiles: (printerId: number, nozzleDiameter = '0.4') => @@ -4813,6 +4916,80 @@ export const api = { method: 'POST', body: JSON.stringify(profiles), }), + + // Finance + getMyBalance: () => request('/finance/me/balance'), + getMyTransactions: (limit = 50, offset = 0) => + request(`/finance/me/transactions?limit=${limit}&offset=${offset}`), + getAllTransactions: (limit = 50, offset = 0, userId?: number) => { + const params = new URLSearchParams(); + params.set('limit', String(limit)); + params.set('offset', String(offset)); + if (userId !== undefined) params.set('user_id', String(userId)); + return request(`/finance/transactions?${params.toString()}`); + }, + deleteTransaction: (transactionId: number) => + request<{ status: string }>(`/finance/transactions/${transactionId}`, { + method: 'DELETE', + }), + editTransaction: (transactionId: number, data: TransactionEditRequest) => + request(`/finance/transactions/${transactionId}`, { + method: 'PATCH', + body: JSON.stringify(data), + }), + createManualPrint: (data: ManualPrintRequest) => + request(`/finance/transactions/manual`, { + method: 'POST', + body: JSON.stringify(data), + }), + getMyCostCenters: () => request('/finance/cost-centers/mine'), + listCostCenters: (includeInactive = false) => + request(`/finance/cost-centers?include_inactive=${includeInactive ? 'true' : 'false'}`), + createCostCenter: (data: CostCenterCreateRequest) => + request('/finance/cost-centers', { + method: 'POST', + body: JSON.stringify(data), + }), + updateCostCenter: (costCenterId: number, data: CostCenterUpdateRequest) => + request(`/finance/cost-centers/${costCenterId}`, { + method: 'PATCH', + body: JSON.stringify(data), + }), + updateCostCenterBudgets: (costCenterId: number, data: CostCenterBudgetUpdateRequest) => + request(`/finance/cost-centers/${costCenterId}/budgets`, { + method: 'PATCH', + body: JSON.stringify(data), + }), + deleteCostCenter: (costCenterId: number) => + request<{ status: string }>(`/finance/cost-centers/${costCenterId}`, { + method: 'DELETE', + }), + getCostCenter: (costCenterId: number) => + request(`/finance/cost-centers/${costCenterId}`), + upsertCostCenterMember: (costCenterId: number, data: CostCenterMemberRequest) => + request(`/finance/cost-centers/${costCenterId}/members`, { + method: 'POST', + body: JSON.stringify(data), + }), + removeCostCenterMember: (costCenterId: number, userId: number) => + request<{ status: string }>(`/finance/cost-centers/${costCenterId}/members/${userId}`, { + method: 'DELETE', + }), + depositUserBalance: (userId: number, data: WalletAdjustmentRequest) => + request(`/finance/users/${userId}/deposit`, { + method: 'POST', + body: JSON.stringify(data), + }), + withdrawUserBalance: (userId: number, data: WalletAdjustmentRequest) => + request(`/finance/users/${userId}/withdraw`, { + method: 'POST', + body: JSON.stringify(data), + }), + rebuildBalanceLedger: () => + request<{ status: string }>(`/finance/rebuild-balance-ledger`, { + method: 'POST', + }), + // K-Profile Notes (stored locally, not on printer) getKProfileNotes: (printerId: number) => @@ -6054,6 +6231,8 @@ export const api = { nozzle_offset_cali?: boolean; project_id?: number; cleanup_library_after_dispatch?: boolean; + cost_center_id?: number; + estimated_cost?: number; } ) => request( diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 6d093729b..a00a810a8 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom'; -import { Printer, Archive, ListOrdered, BarChart3, Cloud, Settings, Sun, Moon, Monitor, ChevronLeft, ChevronRight, Keyboard, Github, ArrowUpCircle, Wrench, FolderKanban, FolderOpen, X, Menu, Info, Plug, Bug, LogOut, Key, Loader2, Disc3, ShieldAlert, Globe, type LucideIcon } from 'lucide-react'; +import { Printer, Archive, ListOrdered, BarChart3, Cloud, Settings, Sun, Moon, Monitor, ChevronLeft, ChevronRight, Keyboard, Github, ArrowUpCircle, Wrench, FolderKanban, FolderOpen, X, Menu, Info, Plug, Bug, LogOut, Key, Loader2, Disc3, ShieldAlert, Globe, Wallet, type LucideIcon } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { useTheme } from '../contexts/ThemeContext'; import { KeyboardShortcutsModal } from './KeyboardShortcutsModal'; @@ -43,6 +43,7 @@ export const defaultNavItems: NavItem[] = [ { id: 'archives', to: '/archives', icon: Archive, labelKey: 'nav.archives' }, { id: 'queue', to: '/queue', icon: ListOrdered, labelKey: 'nav.queue' }, { id: 'projects', to: '/projects', icon: FolderKanban, labelKey: 'nav.projects' }, + { id: 'finance', to: '/finance', icon: Wallet, labelKey: 'nav.finance' }, { id: 'files', to: '/files', icon: FolderOpen, labelKey: 'nav.files' }, { id: 'makerworld', to: '/makerworld', icon: Globe, labelKey: 'nav.makerworld' }, { id: 'profiles', to: '/profiles', icon: Cloud, labelKey: 'nav.profiles' }, @@ -293,6 +294,7 @@ export function Layout() { maintenance: 'maintenance:read', projects: 'projects:read', inventory: 'inventory:read', + finance: 'cost_centers:read_own', files: ['library:read', 'library:read_own', 'library:read_all'], makerworld: 'makerworld:view', settings: 'settings:read', diff --git a/frontend/src/components/PrintModal/CostCenterSelect.tsx b/frontend/src/components/PrintModal/CostCenterSelect.tsx new file mode 100644 index 000000000..f4da18bed --- /dev/null +++ b/frontend/src/components/PrintModal/CostCenterSelect.tsx @@ -0,0 +1,38 @@ +import { useTranslation } from 'react-i18next'; +import type { CostCenterSummary } from '../../api/client'; + +interface CostCenterSelectProps { + costCenters: CostCenterSummary[]; + selectedCostCenterId: number | null; + onChange: (costCenterId: number | null) => void; +} + +export function CostCenterSelect({ + costCenters, + selectedCostCenterId, + onChange, +}: CostCenterSelectProps) { + const { t } = useTranslation(); + + if (costCenters.length === 0) return null; + + return ( +
+ + +
+ ); +} diff --git a/frontend/src/components/PrintModal/FilamentMapping.tsx b/frontend/src/components/PrintModal/FilamentMapping.tsx index 23a01313a..9225c0d0f 100644 --- a/frontend/src/components/PrintModal/FilamentMapping.tsx +++ b/frontend/src/components/PrintModal/FilamentMapping.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { Circle, Check, AlertTriangle, RefreshCw, ChevronDown, ChevronUp, Palette } from 'lucide-react'; @@ -18,6 +18,9 @@ export function FilamentMapping({ filamentReqs, manualMappings, onManualMappingChange, + onEstimatedCostChange, + budgetAvailable, + quantity = 1, currencySymbol, defaultCostPerKg, defaultExpanded = false, @@ -118,10 +121,16 @@ export function FilamentMapping({ return total; }, [filamentComparison, trayCostMap, defaultCostPerKg]); + useEffect(() => { + onEstimatedCostChange?.(totalCost > 0 ? totalCost : null); + }, [onEstimatedCostChange, totalCost]); + const hasAnyCost = useMemo( () => Array.from(trayCostMap.values()).some((v) => v != null && v > 0), [trayCostMap] ); + const budgetCheckCost = totalCost * Math.max(1, quantity); + const isBudgetInsufficient = budgetAvailable != null && budgetCheckCost > budgetAvailable; const hasFilamentReqs = filamentReqs?.filaments && filamentReqs.filaments.length > 0; const isDualNozzle = filamentReqs?.filaments?.some((f) => f.nozzle_id != null) ?? false; @@ -344,7 +353,19 @@ export function FilamentMapping({ {totalCost > 0 || hasAnyCost ? `${currencySymbol}${totalCost.toFixed(2)}` : 'N/A'} + {quantity > 1 && totalCost > 0 && ( + + {t('printModal.totalCostForQuantity', 'total: {{cost}}', { + cost: `${currencySymbol}${budgetCheckCost.toFixed(2)}`, + })} + + )} + {isBudgetInsufficient && ( +

+ {t('printModal.insufficientBudget', 'Insufficient budget for this cost center.')} +

+ )} {hasTypeMismatch && (

Required filament type not found in printer.

)} diff --git a/frontend/src/components/PrintModal/index.tsx b/frontend/src/components/PrintModal/index.tsx index 70396ae9c..18afba1c8 100644 --- a/frontend/src/components/PrintModal/index.tsx +++ b/frontend/src/components/PrintModal/index.tsx @@ -2,7 +2,7 @@ import { useMutation, useQueries, useQuery, useQueryClient } from '@tanstack/rea import { AlertCircle, AlertTriangle, Calendar, Code, Layers, Loader2, Pencil, Printer, X } from 'lucide-react'; import { useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import type { PrintQueueItemCreate, PrintQueueItemUpdate, SpoolAssignment } from '../../api/client'; +import type { PrintQueueItemCreate, PrintQueueItemUpdate, SpoolAssignment, CostCenterSummary } from '../../api/client'; import { api } from '../../api/client'; import { useAuth } from '../../contexts/AuthContext'; import { Card, CardContent } from '../Card'; @@ -22,6 +22,7 @@ import { PlateSelector } from './PlateSelector'; import { PrinterSelector } from './PrinterSelector'; import { PrintOptionsPanel } from './PrintOptions'; import { ScheduleOptionsPanel } from './ScheduleOptions'; +import { CostCenterSelect } from './CostCenterSelect'; import type { AssignmentMode, PrintModalProps, @@ -55,7 +56,7 @@ export function PrintModal({ const { t } = useTranslation(); const queryClient = useQueryClient(); const { showToast } = useToast(); - const { hasPermission } = useAuth(); + const { hasPermission, user } = useAuth(); // Determine if we're printing a library file const isLibraryFile = !!libraryFileId && !archiveId; @@ -179,6 +180,15 @@ export function PrintModal({ return null; }); + // Cost center selection (defaults to queue item's value in edit mode) + const [selectedCostCenterId, setSelectedCostCenterId] = useState(() => { + if (mode === 'edit-queue-item' && queueItem?.cost_center_id != null) { + return queueItem.cost_center_id; + } + return null; + }); + const [estimatedCost, setEstimatedCost] = useState(queueItem?.estimated_cost ?? null); + // Filament overrides for model-based assignment: slot_id -> {type, color} const [filamentOverrides, setFilamentOverrides] = useState>(() => { if (mode === 'edit-queue-item' && queueItem?.filament_overrides) { @@ -257,12 +267,37 @@ export function PrintModal({ const currencySymbol = getCurrencySymbol(settings?.currency || 'USD'); const defaultCostPerKg = settings?.default_filament_cost ?? 0; + const billingEnabled = settings?.billing_enabled === true; const { data: printers, isLoading: loadingPrinters } = useQuery({ queryKey: ['printers'], queryFn: api.getPrinters, }); + const { data: myCostCenters } = useQuery({ + queryKey: ['finance', 'cost-centers', 'mine'], + queryFn: api.getMyCostCenters, + enabled: !!user && billingEnabled, + }); + + const printableCostCenters = useMemo( + () => (myCostCenters || []).filter((center: CostCenterSummary) => center.can_print && center.is_active), + [myCostCenters] + ); + const selectedCostCenter = useMemo( + () => printableCostCenters.find((center) => center.id === selectedCostCenterId) ?? null, + [printableCostCenters, selectedCostCenterId] + ); + + // Select default cost center once loaded (prefer private/personal) + useEffect(() => { + if (printableCostCenters.length === 0) return; + if (selectedCostCenterId != null && printableCostCenters.some((c) => c.id === selectedCostCenterId)) return; + + const preferredPrivate = printableCostCenters.find((c) => c.is_private); + setSelectedCostCenterId(preferredPrivate ? preferredPrivate.id : printableCostCenters[0].id); + }, [printableCostCenters, selectedCostCenterId]); + const { data: spoolAssignments } = useQuery({ queryKey: ['spool-assignments'], queryFn: () => api.getAssignments(), @@ -733,6 +768,8 @@ export function PrintModal({ : undefined, ...printOptions, project_id: projectId ?? undefined, + cost_center_id: billingEnabled ? selectedCostCenterId : undefined, + estimated_cost: billingEnabled && selectedCostCenterId != null ? estimatedCost : undefined, batch_id: autoBatchId ?? undefined, }); @@ -768,6 +805,8 @@ export function PrintModal({ ? new Date(scheduleOptions.scheduledTime).toISOString() : null, ...printOptions, + cost_center_id: billingEnabled ? selectedCostCenterId : undefined, + estimated_cost: billingEnabled && selectedCostCenterId != null ? estimatedCost : undefined, }; await updateQueueMutation.mutateAsync(updateData); } else { @@ -826,7 +865,9 @@ export function PrintModal({ ...printOptions, project_id: projectId, cleanup_library_after_dispatch: cleanupLibraryAfterDispatch, - }); + cost_center_id: billingEnabled ? selectedCostCenterId ?? undefined : undefined, + estimated_cost: billingEnabled && selectedCostCenterId != null ? estimatedCost ?? undefined : undefined, + }); } else { // project_id is intentionally omitted here: reprintArchive targets an existing // archive that already carries its own project association from the original print. @@ -835,7 +876,9 @@ export function PrintModal({ plate_name: selectedPlateName, ams_mapping: printerMapping, ...printOptions, - }); + cost_center_id: billingEnabled ? selectedCostCenterId ?? undefined : undefined, + estimated_cost: billingEnabled && selectedCostCenterId != null ? estimatedCost ?? undefined : undefined, + }); } // Queue remaining copies if quantity > 1 if (effectiveQuantity > 1) { @@ -861,6 +904,8 @@ export function PrintModal({ ? new Date(scheduleOptions.scheduledTime).toISOString() : null, ...printOptions, + cost_center_id: billingEnabled ? selectedCostCenterId : undefined, + estimated_cost: billingEnabled && selectedCostCenterId != null ? estimatedCost : undefined, }; await updateQueueMutation.mutateAsync(updateData); } else { @@ -1020,6 +1065,12 @@ export function PrintModal({ isLibraryFile || (isMultiPlate ? selectedPlate !== null : true) ); + useEffect(() => { + if (!showFilamentMapping || archiveDataMissing || selectedPrinters.length !== 1) { + setEstimatedCost(null); + } + }, [archiveDataMissing, selectedPrinters.length, showFilamentMapping]); + // Dual-nozzle gate for the Nozzle Offset Calibration toggle (#1682). // Mirrors backend `DUAL_NOZZLE_MODELS` so model-based assignment can show // the toggle without a specific printer selected. For printer-mode we rely @@ -1199,6 +1250,9 @@ export function PrintModal({ filamentReqs={effectiveFilamentReqs} manualMappings={manualMappings} onManualMappingChange={setManualMappings} + onEstimatedCostChange={setEstimatedCost} + budgetAvailable={billingEnabled ? selectedCostCenter?.budget_available ?? null : null} + quantity={effectiveQuantity} defaultExpanded={!!initialSelectedPrinterIds?.length || (settings?.per_printer_mapping_expanded ?? false)} currencySymbol={currencySymbol} defaultCostPerKg={defaultCostPerKg} @@ -1217,6 +1271,12 @@ export function PrintModal({ defaultExpanded={!!initialSelectedPrinterIds?.length} showDualNozzleOptions={showDualNozzleOptions} /> + {billingEnabled && printableCostCenters.length > 0 && ( + )} {/* Quantity — create multiple copies (batch). Hidden for multi-printer selection. */} @@ -1311,6 +1371,13 @@ export function PrintModal({ )} + {/* Print options */} + {(mode === 'reprint' || effectivePrinterCount > 0 || (assignmentMode === 'model' && targetModel)) && ( +
+ +
+ )} + {/* Error message */} {updateQueueMutation.isError && (
@@ -1319,7 +1386,7 @@ export function PrintModal({ )} {/* Actions */} -
+
diff --git a/frontend/src/components/PrintModal/types.ts b/frontend/src/components/PrintModal/types.ts index 5faa3be99..6dc9a4a25 100644 --- a/frontend/src/components/PrintModal/types.ts +++ b/frontend/src/components/PrintModal/types.ts @@ -199,6 +199,9 @@ export interface FilamentMappingProps { filamentReqs: FilamentReqsData | undefined; manualMappings: Record; onManualMappingChange: (mappings: Record) => void; + onEstimatedCostChange?: (estimatedCost: number | null) => void; + budgetAvailable?: number | null; + quantity?: number; currencySymbol: string; defaultCostPerKg: number; /** Per-slot force-color-match flags. The scheduler honors this flag in both diff --git a/frontend/src/pages/FinancePage.tsx b/frontend/src/pages/FinancePage.tsx new file mode 100644 index 000000000..10b207b33 --- /dev/null +++ b/frontend/src/pages/FinancePage.tsx @@ -0,0 +1,1465 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { Building2, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Clock3, Pencil, Trash2, Wallet } from 'lucide-react'; +import { useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { api, type ManualPrintRequest, type TransactionEditRequest, type WalletTransaction } from '../api/client'; +import { Button } from '../components/Button'; +import { Card, CardContent, CardHeader } from '../components/Card'; +import { useAuth } from '../contexts/AuthContext'; +import { useToast } from '../contexts/ToastContext'; +import { getCurrencySymbol } from '../utils/currency'; +import { parseUTCDate } from '../utils/date'; + +type PartialPrintStatus = 'aborted' | 'failed' | 'cancelled'; + +interface ParsedPrintChargeDescription { + isPartial: boolean; + partialType: PartialPrintStatus | null; + cleanedDescription: string | null; +} + +const PRINT_CHARGE_PARTIAL_REGEX = /\[(aborted|failed|cancelled):\s*([^\]]*)\]/i; + +function parsePrintChargeDescription(description: string | null): ParsedPrintChargeDescription { + if (!description) { + return { isPartial: false, partialType: null, cleanedDescription: null }; + } + + const match = description.match(PRINT_CHARGE_PARTIAL_REGEX); + if (!match) { + return { isPartial: false, partialType: null, cleanedDescription: description }; + } + + const cleanedDescription = description.replace(match[0], '').trim(); + return { + isPartial: true, + partialType: match[1].toLowerCase() as PartialPrintStatus, + cleanedDescription: cleanedDescription || null, + }; +} + +function formatTimestamp(value: string | null, locale: string): string { + if (!value) return '-'; + const parsed = parseUTCDate(value); + if (!parsed) return value; + return parsed.toLocaleString(locale); +} + +function parseBudgetValue(raw: string): number | null { + const trimmed = raw.trim(); + if (!trimmed) return null; + const parsed = Number.parseFloat(trimmed); + return Number.isFinite(parsed) ? parsed : null; +} + +function formatLocalDateTime(value: Date): string { + const year = value.getFullYear(); + const month = String(value.getMonth() + 1).padStart(2, '0'); + const day = String(value.getDate()).padStart(2, '0'); + const hours = String(value.getHours()).padStart(2, '0'); + const minutes = String(value.getMinutes()).padStart(2, '0'); + return `${year}-${month}-${day}T${hours}:${minutes}`; +} + +const fieldClass = + 'w-full px-3 py-2 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded text-white placeholder-bambu-gray focus:outline-none focus:ring-1 focus:ring-bambu-green'; +const labelClass = 'block text-sm font-medium text-white mb-1'; +const tableHeadCellClass = 'px-4 py-3 text-left text-bambu-gray font-medium'; +const tableCellClass = 'px-4 py-3 align-top text-white'; + +interface FinanceModalProps { + title: string; + onClose: () => void; + children: React.ReactNode; + size?: 'sm' | 'md' | 'lg'; +} + +function FinanceModal({ title, onClose, children, size = 'md' }: FinanceModalProps) { + const { t } = useTranslation(); + const sizeClass = size === 'sm' ? 'max-w-xl' : size === 'lg' ? 'max-w-6xl' : 'max-w-4xl'; + + return ( +
+
+
+

{title}

+ +
+
{children}
+
+
+ ); +} + +export function FinancePage() { + const { t, i18n } = useTranslation(); + const { hasPermission, user } = useAuth(); + const { showToast } = useToast(); + const queryClient = useQueryClient(); + + const canReadOwn = hasPermission('cost_centers:read_own'); + const canReadAllFinance = hasPermission('cost_centers:read_all'); + const canCreateCostCenters = hasPermission('cost_centers:create'); + const canUpdateCostCenters = hasPermission('cost_centers:modify'); + const canUpdateBudgets = hasPermission('cost_centers:modify'); + const canAssignCostCenterUsers = hasPermission('cost_centers:modify'); + const canAdjustWallet = hasPermission('cost_centers:modify'); + const canReadUsers = hasPermission('users:read'); + + const canAccessAllCostCenters = + canReadAllFinance || + canCreateCostCenters || + canUpdateCostCenters || + canUpdateBudgets || + canAssignCostCenterUsers || + canAdjustWallet; + + const canAccessFinance = canReadOwn || canAccessAllCostCenters; + const canViewMyCostCenters = canAccessFinance; + + const [newCenterName, setNewCenterName] = useState(''); + const [newCenterBudgetMode, setNewCenterBudgetMode] = useState<'total' | 'monthly'>('monthly'); + const [newCenterBudgetValue, setNewCenterBudgetValue] = useState(''); + + const [selectedUserId, setSelectedUserId] = useState(null); + const [selectedAdjustmentType, setSelectedAdjustmentType] = useState<'deposit' | 'withdraw'>('deposit'); + const [adjustmentAmount, setAdjustmentAmount] = useState(''); + const [adjustmentDescription, setAdjustmentDescription] = useState(''); + const [adjustmentCostCenterId, setAdjustmentCostCenterId] = useState(null); + + const [selectedManageCenterId, setSelectedManageCenterId] = useState(null); + const [memberUserId, setMemberUserId] = useState(null); + const [memberCanPrint, setMemberCanPrint] = useState(true); + + const [txOffset, setTxOffset] = useState(0); + const txLimit = 50; + const [txTypeFilter, setTxTypeFilter] = useState('all'); + const [txCostCenterFilter, setTxCostCenterFilter] = useState('all'); + + const [showCreateCenterModal, setShowCreateCenterModal] = useState(false); + const [showAdjustWalletModal, setShowAdjustWalletModal] = useState(false); + const [showMembersModal, setShowMembersModal] = useState(false); + const [showEditCenterModal, setShowEditCenterModal] = useState(false); + const [financeViewMode, setFinanceViewMode] = useState<'personal' | 'admin'>('personal'); + const [selectedEditCenterId, setSelectedEditCenterId] = useState(null); + const [editCenterName, setEditCenterName] = useState(''); + const [editCenterBudgetMode, setEditCenterBudgetMode] = useState<'total' | 'monthly'>('monthly'); + const [editCenterBudgetValue, setEditCenterBudgetValue] = useState(''); + + const [showEditTransactionModal, setShowEditTransactionModal] = useState(false); + const [selectedEditTransactionId, setSelectedEditTransactionId] = useState(null); + const [editTransactionUserId, setEditTransactionUserId] = useState(null); + const [editTransactionCostCenterId, setEditTransactionCostCenterId] = useState(null); + const [editTransactionAmount, setEditTransactionAmount] = useState(''); + const [editTransactionDescription, setEditTransactionDescription] = useState(''); + + const [showManualPrintModal, setShowManualPrintModal] = useState(false); + const [manualPrintUserId, setManualPrintUserId] = useState(null); + const [manualPrintCostCenterId, setManualPrintCostCenterId] = useState(null); + const [manualPrintAmount, setManualPrintAmount] = useState(''); + const [manualPrintDescription, setManualPrintDescription] = useState(''); + const [manualPrintDate, setManualPrintDate] = useState(formatLocalDateTime(new Date())); + + const hasAdminFinanceControls = + canReadAllFinance || + canCreateCostCenters || + canUpdateCostCenters || + canUpdateBudgets || + (canAdjustWallet && canReadUsers) || + (canAssignCostCenterUsers && canReadUsers); + + useEffect(() => { + if (!hasAdminFinanceControls) { + setFinanceViewMode('personal'); + } + }, [hasAdminFinanceControls]); + + const { data: wallet, isLoading: walletLoading } = useQuery({ + queryKey: ['finance', 'me', 'balance'], + queryFn: api.getMyBalance, + enabled: canReadOwn, + }); + + const { data: transactionsResponse, isLoading: personalTxLoading } = useQuery({ + queryKey: ['finance', 'me', 'transactions', txLimit, txOffset], + queryFn: () => api.getMyTransactions(txLimit, txOffset), + enabled: canReadOwn, + }); + + const { data: adminTransactionsResponse, isLoading: adminTxLoading } = useQuery({ + queryKey: ['finance', 'transactions', txLimit, txOffset], + queryFn: () => api.getAllTransactions(txLimit, txOffset), + enabled: financeViewMode === 'admin' && canReadAllFinance, + }); + + const { data: costCenters, isLoading: centersLoading } = useQuery({ + queryKey: ['finance', 'cost-centers', financeViewMode], + queryFn: () => (financeViewMode === 'admin' && canAccessAllCostCenters ? api.listCostCenters(true) : api.getMyCostCenters()), + enabled: canViewMyCostCenters, + }); + + const { data: users } = useQuery({ + queryKey: ['users'], + queryFn: api.getUsers, + enabled: canReadUsers && (canViewMyCostCenters || canAdjustWallet || canAssignCostCenterUsers), + }); + + const { data: selectedCenterDetail } = useQuery({ + queryKey: ['finance', 'cost-center', selectedManageCenterId], + queryFn: () => api.getCostCenter(selectedManageCenterId!), + enabled: canAssignCostCenterUsers && selectedManageCenterId != null, + }); + + useEffect(() => { + if (!users || users.length === 0) return; + if (selectedUserId != null && users.some((u) => u.id === selectedUserId)) return; + setSelectedUserId(users[0].id); + }, [users, selectedUserId]); + + useEffect(() => { + if (!canAssignCostCenterUsers) return; + const sharedCenters = (costCenters || []).filter((c) => !c.is_private); + if (sharedCenters.length === 0) { + setSelectedManageCenterId(null); + return; + } + if (selectedManageCenterId != null && sharedCenters.some((c) => c.id === selectedManageCenterId)) return; + setSelectedManageCenterId(sharedCenters[0].id); + }, [canAssignCostCenterUsers, costCenters, selectedManageCenterId]); + + useEffect(() => { + if (!canAssignCostCenterUsers || !users || users.length === 0) return; + const existingMemberIds = new Set((selectedCenterDetail?.members || []).map((m) => m.user_id)); + const firstAvailable = users.find((u) => !existingMemberIds.has(u.id)); + setMemberUserId(firstAvailable ? firstAvailable.id : null); + }, [canAssignCostCenterUsers, users, selectedCenterDetail]); + + useEffect(() => { + if (!showEditCenterModal) return; + const editableCenters = costCenters || []; + if (editableCenters.length === 0) { + setSelectedEditCenterId(null); + return; + } + if (selectedEditCenterId != null && editableCenters.some((center) => center.id === selectedEditCenterId)) return; + setSelectedEditCenterId(editableCenters[0].id); + }, [showEditCenterModal, selectedEditCenterId, costCenters]); + + useEffect(() => { + if (!showEditCenterModal || selectedEditCenterId == null) return; + const center = (costCenters || []).find((entry) => entry.id === selectedEditCenterId); + if (!center) return; + setEditCenterName(center.name); + if (center.budget_mode === 'total') { + setEditCenterBudgetMode('total'); + setEditCenterBudgetValue(center.total_budget == null ? '' : String(center.total_budget)); + return; + } + setEditCenterBudgetMode('monthly'); + setEditCenterBudgetValue(center.monthly_budget == null ? '' : String(center.monthly_budget)); + }, [showEditCenterModal, selectedEditCenterId, costCenters]); + + const createCostCenterMutation = useMutation({ + mutationFn: () => + api.createCostCenter({ + name: newCenterName.trim(), + total_budget: newCenterBudgetMode === 'total' ? parseBudgetValue(newCenterBudgetValue) : null, + monthly_budget: newCenterBudgetMode === 'monthly' ? parseBudgetValue(newCenterBudgetValue) : null, + is_active: true, + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['finance'] }); + setNewCenterName(''); + setNewCenterBudgetValue(''); + showToast(t('finance.createdCostCenter', 'Cost center created')); + }, + onError: (error: Error) => { + showToast(error.message || t('finance.createCostCenterFailed', 'Failed to create cost center'), 'error'); + }, + }); + + const updateBudgetMutation = useMutation({ + mutationFn: ({ costCenterId, value, mode }: { costCenterId: number; value: string; mode: 'total' | 'monthly' }) => + api.updateCostCenterBudgets(costCenterId, { + total_budget: mode === 'total' ? parseBudgetValue(value) : null, + monthly_budget: mode === 'monthly' ? parseBudgetValue(value) : null, + }), + }); + + const updateCostCenterMutation = useMutation({ + mutationFn: ({ costCenterId, name }: { costCenterId: number; name: string }) => + api.updateCostCenter(costCenterId, { + name, + }), + }); + + const deleteCostCenterMutation = useMutation({ + mutationFn: (costCenterId: number) => api.deleteCostCenter(costCenterId), + }); + + const deleteTransactionMutation = useMutation({ + mutationFn: (transactionId: number) => api.deleteTransaction(transactionId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['finance'] }); + showToast(t('finance.transactionDeleted', 'Transaction deleted')); + }, + onError: (error: Error) => { + showToast(error.message || t('finance.deleteTransactionFailed', 'Failed to delete transaction'), 'error'); + }, + }); + + const editTransactionMutation = useMutation({ + mutationFn: (payload: { transactionId: number; data: TransactionEditRequest }) => + api.editTransaction(payload.transactionId, payload.data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['finance'] }); + showToast(t('finance.transactionEdited', 'Transaction updated and ledger recalculated')); + setShowEditTransactionModal(false); + }, + onError: (error: Error) => { + showToast(error.message || t('finance.editTransactionFailed', 'Failed to edit transaction'), 'error'); + }, + }); + + const manualPrintMutation = useMutation({ + mutationFn: (data: ManualPrintRequest) => api.createManualPrint(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['finance'] }); + showToast(t('finance.manualPrintCreated', 'Manual print charge added and ledger recalculated')); + setShowManualPrintModal(false); + setManualPrintUserId(null); + setManualPrintCostCenterId(null); + setManualPrintAmount(''); + setManualPrintDescription(''); + setManualPrintDate(formatLocalDateTime(new Date())); + }, + onError: (error: Error) => { + showToast(error.message || t('finance.manualPrintFailed', 'Failed to create manual print'), 'error'); + }, + }); + + const depositMutation = useMutation({ + mutationFn: (payload: { userId: number; amount: number; description?: string; costCenterId?: number | null }) => + api.depositUserBalance(payload.userId, { + amount: payload.amount, + description: payload.description, + cost_center_id: payload.costCenterId ?? null, + }), + }); + + const withdrawMutation = useMutation({ + mutationFn: (payload: { userId: number; amount: number; description?: string; costCenterId?: number | null }) => + api.withdrawUserBalance(payload.userId, { + amount: payload.amount, + description: payload.description, + cost_center_id: payload.costCenterId ?? null, + }), + }); + + const upsertMemberMutation = useMutation({ + mutationFn: (payload: { costCenterId: number; userId: number; canPrint: boolean }) => + api.upsertCostCenterMember(payload.costCenterId, { + user_id: payload.userId, + can_print: payload.canPrint, + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['finance'] }); + showToast(t('finance.memberSaved', 'Member saved')); + }, + onError: (error: Error) => { + showToast(error.message || t('finance.memberSaveFailed', 'Failed to save member'), 'error'); + }, + }); + + const removeMemberMutation = useMutation({ + mutationFn: (payload: { costCenterId: number; userId: number }) => + api.removeCostCenterMember(payload.costCenterId, payload.userId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['finance'] }); + showToast(t('finance.memberRemoved', 'Member removed')); + }, + onError: (error: Error) => { + showToast(error.message || t('finance.memberRemoveFailed', 'Failed to remove member'), 'error'); + }, + }); + + const isAdjustingWallet = depositMutation.isPending || withdrawMutation.isPending; + + const handleCreateCostCenter = () => { + if (!newCenterName.trim()) { + showToast(t('finance.costCenterNameRequired', 'Cost center name is required'), 'error'); + return; + } + createCostCenterMutation.mutate(); + }; + + const handleSaveEditedCenter = async () => { + if (selectedEditCenterId == null) { + showToast(t('finance.selectCostCenter', 'Please select a cost center'), 'error'); + return; + } + + const name = editCenterName.trim(); + if (!name) { + showToast(t('finance.costCenterNameRequired', 'Cost center name is required'), 'error'); + return; + } + + try { + if (canUpdateCostCenters) { + await updateCostCenterMutation.mutateAsync({ costCenterId: selectedEditCenterId, name }); + } + if (canUpdateBudgets) { + await updateBudgetMutation.mutateAsync({ + costCenterId: selectedEditCenterId, + mode: editCenterBudgetMode, + value: editCenterBudgetValue, + }); + } + queryClient.invalidateQueries({ queryKey: ['finance'] }); + showToast(t('finance.costCenterUpdated', 'Cost center updated')); + setShowEditCenterModal(false); + } catch (error) { + showToast((error as Error).message || t('finance.costCenterUpdateFailed', 'Failed to update cost center'), 'error'); + } + }; + + const handleOpenEditCenter = (costCenterId: number) => { + const center = (costCenters || []).find((entry) => entry.id === costCenterId); + if (!center) return; + + setSelectedEditCenterId(center.id); + setEditCenterName(center.name); + if (center.budget_mode === 'total') { + setEditCenterBudgetMode('total'); + setEditCenterBudgetValue(center.total_budget == null ? '' : String(center.total_budget)); + } else { + setEditCenterBudgetMode('monthly'); + setEditCenterBudgetValue(center.monthly_budget == null ? '' : String(center.monthly_budget)); + } + setShowEditCenterModal(true); + }; + + const handleDeleteCenter = async (centerId: number, centerName: string) => { + const confirmed = window.confirm( + t('finance.confirmDeleteCostCenter', 'Delete cost center "{{name}}"?', { name: centerName }) + ); + if (!confirmed) return; + + try { + await deleteCostCenterMutation.mutateAsync(centerId); + queryClient.invalidateQueries({ queryKey: ['finance'] }); + if (selectedManageCenterId === centerId) { + setSelectedManageCenterId(null); + } + showToast(t('finance.costCenterDeleted', 'Cost center deleted')); + } catch (error) { + showToast((error as Error).message || t('finance.costCenterDeleteFailed', 'Failed to delete cost center'), 'error'); + } + }; + + const handleDeleteTransaction = (transactionId: number) => { + const confirmed = window.confirm( + t('finance.deleteTransactionConfirm', 'Delete this transaction? Balances will be recalculated automatically.') + ); + if (!confirmed) return; + + deleteTransactionMutation.mutate(transactionId); + }; + + const handleEditTransaction = (tx: WalletTransaction) => { + setSelectedEditTransactionId(tx.id); + setEditTransactionUserId(tx.user_id); + setEditTransactionCostCenterId(tx.cost_center_id); + setEditTransactionAmount(String(tx.amount)); + setEditTransactionDescription(tx.description || ''); + setShowEditTransactionModal(true); + }; + + const handleSaveEditTransaction = async () => { + if (selectedEditTransactionId == null) return; + + const amount = editTransactionAmount ? Number.parseFloat(editTransactionAmount) : null; + if (amount !== null && !Number.isFinite(amount)) { + showToast(t('finance.amountInvalid', 'Amount must be a valid number'), 'error'); + return; + } + + editTransactionMutation.mutate({ + transactionId: selectedEditTransactionId, + data: { + user_id: editTransactionUserId || undefined, + cost_center_id: editTransactionCostCenterId || undefined, + amount: amount || undefined, + description: editTransactionDescription || undefined, + }, + }); + }; + + const handleSaveManualPrint = async () => { + if (manualPrintUserId == null) { + showToast(t('finance.userRequired', 'Please select a user'), 'error'); + return; + } + if (manualPrintCostCenterId == null) { + showToast(t('finance.costCenterRequired', 'Please select a cost center'), 'error'); + return; + } + + let amount = manualPrintAmount ? Number.parseFloat(manualPrintAmount) : null; + if (amount === null || !Number.isFinite(amount)) { + showToast(t('finance.amountInvalid', 'Amount must be a valid number'), 'error'); + return; + } + if (amount > 0) amount = -Math.abs(amount); + + manualPrintMutation.mutate({ + user_id: manualPrintUserId, + cost_center_id: manualPrintCostCenterId, + amount: amount, + description: manualPrintDescription || undefined, + created_at: manualPrintDate ? new Date(manualPrintDate).toISOString() : undefined, + }); + }; + + const handleWalletAdjustment = async () => { + if (selectedUserId == null) { + showToast(t('finance.userRequired', 'Please select a user'), 'error'); + return; + } + + const amount = Number.parseFloat(adjustmentAmount); + if (!Number.isFinite(amount) || amount <= 0) { + showToast(t('finance.amountMustBePositive', 'Amount must be greater than zero'), 'error'); + return; + } + + const payload = { + userId: selectedUserId, + amount, + description: adjustmentDescription.trim() || undefined, + costCenterId: adjustmentCostCenterId, + }; + + try { + if (selectedAdjustmentType === 'deposit') { + await depositMutation.mutateAsync(payload); + } else { + await withdrawMutation.mutateAsync(payload); + } + queryClient.invalidateQueries({ queryKey: ['finance'] }); + setAdjustmentAmount(''); + setAdjustmentDescription(''); + showToast( + selectedAdjustmentType === 'deposit' + ? t('finance.depositSuccess', 'Deposit successful') + : t('finance.withdrawSuccess', 'Withdrawal successful') + ); + } catch (error) { + showToast((error as Error).message || t('finance.adjustmentFailed', 'Wallet adjustment failed'), 'error'); + } + }; + + const handleAddMember = () => { + if (selectedManageCenterId == null) { + showToast(t('finance.selectCostCenter', 'Please select a cost center'), 'error'); + return; + } + if (memberUserId == null) { + showToast(t('finance.noEligibleUsers', 'No eligible users available'), 'error'); + return; + } + upsertMemberMutation.mutate({ + costCenterId: selectedManageCenterId, + userId: memberUserId, + canPrint: memberCanPrint, + }); + }; + + const handleRemoveMember = (userId: number) => { + if (selectedManageCenterId == null) return; + removeMemberMutation.mutate({ costCenterId: selectedManageCenterId, userId }); + }; + + const currency = wallet?.currency || 'EUR'; + const currencySymbol = getCurrencySymbol(currency); + + const sortedUsers = useMemo(() => { + return [...(users || [])].sort((a, b) => a.username.localeCompare(b.username)); + }, [users]); + + const usersById = useMemo(() => { + const map = new Map(); + for (const entry of sortedUsers) { + map.set(entry.id, entry.username); + } + return map; + }, [sortedUsers]); + + const availableUsersForCenter = useMemo(() => { + const existingIds = new Set((selectedCenterDetail?.members || []).map((m) => m.user_id)); + return sortedUsers.filter((u) => !existingIds.has(u.id)); + }, [sortedUsers, selectedCenterDetail]); + + const activeTransactionsResponse = + financeViewMode === 'admin' && canReadAllFinance + ? adminTransactionsResponse + : transactionsResponse; + + const txLoading = (financeViewMode === 'admin' && canReadAllFinance) + ? adminTxLoading + : personalTxLoading; + + const transactions = useMemo( + () => activeTransactionsResponse?.items ?? [], + [activeTransactionsResponse?.items] + ); + const txTotal = activeTransactionsResponse?.total ?? 0; + const txTotalPages = Math.max(1, Math.ceil(txTotal / txLimit)); + + const filteredTransactions = useMemo(() => { + const items = transactions; + return items.filter((tx) => { + if (txTypeFilter !== 'all' && tx.transaction_type !== txTypeFilter) return false; + if (txCostCenterFilter !== 'all' && tx.cost_center_id !== txCostCenterFilter) return false; + return true; + }); + }, [transactions, txTypeFilter, txCostCenterFilter]); + + const txPage = Math.floor(txOffset / txLimit) + 1; + const showCostCenterAccountColumn = financeViewMode === 'admin'; + + const getTransactionTypeLabel = (transactionType: string): string => { + if (transactionType === 'deposit') return t('finance.deposit'); + if (transactionType === 'withdraw') return t('finance.withdraw'); + if (transactionType === 'print_charge') return t('finance.printCharge'); + return transactionType; + }; + + const getPrivateOwnerLabel = (ownerUserId: number | null): string => { + if (ownerUserId == null) return t('finance.personal', 'Personal'); + const ownerName = usersById.get(ownerUserId); + if (ownerName) return ownerName; + if (user?.id === ownerUserId && user.username) return user.username; + return t('finance.userWithId', 'User #{{id}}', { id: ownerUserId }); + }; + + const formatBudgetProgress = (center: { budget_available: number | null; budget_limit: number | null }) => { + if (center.budget_limit == null || center.budget_available == null) return '-'; + return `${currencySymbol}${center.budget_available.toFixed(2)}/${currencySymbol}${center.budget_limit.toFixed(2)}`; + }; + + if (!canAccessFinance) { + return ( +
+
+ +

{t('finance.title', 'Finance')}

+
+
+

{t('finance.subtitle', 'Wallet, personal transactions, and cost centers')}

+
+ + + {t('finance.noAccess', 'You do not have permission to view finance data.')} + + +
+ ); + } + + return ( +
+
+
+
+ +

{t('finance.title', 'Finance')}

+
+

{t('finance.subtitle', 'Wallet, personal transactions, and cost centers')}

+
+ +
+ {hasAdminFinanceControls && ( +
+ + +
+ )} + + {financeViewMode === 'admin' && hasAdminFinanceControls && ( +
+ {canCreateCostCenters && ( + + )} + {canAdjustWallet && canReadUsers && ( + + )} + {canAdjustWallet && canReadUsers && ( + + )} + {canAssignCostCenterUsers && canReadUsers && ( + + )} +
+ )} +
+
+ +
+ + + {t('finance.currentBalance', 'Personal balance')} + + + +

+ {walletLoading ? t('common.loading', 'Loading...') : `${currencySymbol}${(wallet?.balance ?? 0).toFixed(2)}`} +

+
+
+ + + + + {financeViewMode === 'admin' + ? t('finance.transactions', 'Transactions') + : t('finance.personalTransactions', 'Personal transactions')} + + + + +

{txLoading ? '-' : txTotal}

+
+
+ + + + {t('finance.costCenters', 'Cost centers')} + + + +

{centersLoading ? '-' : costCenters?.length ?? 0}

+
+
+
+ + {showCreateCenterModal && canCreateCostCenters && ( + setShowCreateCenterModal(false)}> +
+
+
+ + setNewCenterName(e.target.value)} + placeholder={t('finance.costCenterName', 'Name')} + className={fieldClass} + /> +
+
+ + +
+
+ + setNewCenterBudgetValue(e.target.value)} + placeholder="0.00" + className={fieldClass} + /> +
+
+ +
+
+ )} + + {showAdjustWalletModal && canAdjustWallet && canReadUsers && ( + setShowAdjustWalletModal(false)}> +
+
+
+ + +
+ +
+ + +
+ +
+ + setAdjustmentAmount(e.target.value)} + placeholder="0.00" + className={fieldClass} + /> +
+ +
+ + +
+
+ +
+ + setAdjustmentDescription(e.target.value)} + placeholder={t('finance.descriptionOptional', 'Description (optional)')} + className={fieldClass} + /> +
+ + +
+
+ )} + + {showMembersModal && canAssignCostCenterUsers && canReadUsers && ( + setShowMembersModal(false)}> +
+
+
+ + +
+ +
+ + +
+ +
+ setMemberCanPrint(e.target.checked)} + className="rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green" + /> + +
+ +
+ +
+
+ +
+ + + + + + + + + + {(selectedCenterDetail?.members || []).map((member) => { + const user = sortedUsers.find((u) => u.id === member.user_id); + return ( + + + + + + ); + })} + {(selectedCenterDetail?.members || []).length === 0 && ( + + + + )} + +
{t('common.name', 'Name')}{t('finance.canPrint', 'Can print')}{t('common.actions', 'Actions')}
{user?.username || `User ${member.user_id}`}{member.can_print ? t('common.yes', 'Yes') : t('common.no', 'No')} + +
+ {t('finance.noMembers', 'No members assigned.')} +
+
+
+
+ )} + + {showEditCenterModal && (canUpdateCostCenters || canUpdateBudgets) && ( + setShowEditCenterModal(false)}> +
+
+ + setEditCenterName(e.target.value)} + placeholder={t('finance.costCenterName', 'Name')} + className={fieldClass} + disabled={!canUpdateCostCenters} + /> +
+ +
+
+ + +
+ +
+ + setEditCenterBudgetValue(e.target.value)} + placeholder="0.00" + className={fieldClass} + disabled={!canUpdateBudgets} + /> +
+
+ + +
+
+ )} + +
+ {canViewMyCostCenters && ( + + +

{t('finance.myCostCenters', 'My cost centers')}

+

{t('finance.costCentersHint', 'Review budget limits and keep costs under control')}

+
+ + {centersLoading &&

{t('common.loading', 'Loading...')}

} + {!centersLoading && (!costCenters || costCenters.length === 0) && ( +

{t('finance.noCostCenters', 'No cost centers found.')}

+ )} + {!centersLoading && costCenters && costCenters.length > 0 && ( +
+ + + + + {showCostCenterAccountColumn && } + + + {showCostCenterAccountColumn && } + + + + {costCenters.map((center) => { + const canEditRow = canUpdateCostCenters || canUpdateBudgets; + const canDeleteRow = !center.is_private && canUpdateCostCenters; + return ( + + + {showCostCenterAccountColumn && ( + + )} + + + {showCostCenterAccountColumn && ( + + )} + + ); + })} + +
{t('common.name', 'Name')}{t('finance.owner', 'Owner')}{t('finance.balance', 'Balance')}{t('finance.budget', 'Budget')}{t('common.actions', 'Actions')}
{center.name}{center.is_private ? getPrivateOwnerLabel(center.owner_user_id) : t('finance.shared', 'Shared')}{currencySymbol}{center.total_balance.toFixed(2)} +
+ {formatBudgetProgress(center)} + + {center.budget_mode === 'monthly' + ? t('finance.monthlyBudget', 'Monthly budget') + : center.budget_mode === 'total' + ? t('finance.totalBudget', 'Total budget') + : t('finance.noBudget', 'No budget')} + +
+
+
+ + {canDeleteRow && ( + + )} +
+
+
+ )} +
+
+ )} + + {canReadOwn && ( + + +

{t('finance.recentTransactions', 'Recent transactions')}

+

{t('finance.transactionsHint', 'Filter by type and cost center, then navigate pages')}

+
+ +
+
+ + +
+ +
+ + +
+ +
+ + {txLoading &&

{t('common.loading', 'Loading...')}

} + {!txLoading && (!transactions || transactions.length === 0) && ( +

{t('finance.noTransactions', 'No transactions available.')}

+ )} + {!txLoading && transactions.length > 0 && filteredTransactions.length === 0 && ( +

{t('finance.noTransactionsForFilter', 'No transactions match the selected filters.')}

+ )} + {!txLoading && filteredTransactions.length > 0 && ( +
+ + + + + {showCostCenterAccountColumn && } + + + + + + {showCostCenterAccountColumn && } + + + + {filteredTransactions.map((tx) => { + const positive = tx.amount >= 0; + const txCostCenter = tx.cost_center_id == null + ? null + : (costCenters || []).find((center) => center.id === tx.cost_center_id); + const txUser = usersById.get(tx.user_id); + const parsed = tx.transaction_type === 'print_charge' + ? parsePrintChargeDescription((tx as { description: string | null }).description ?? null) + : null; + const txIsPartialPrint = parsed?.isPartial ?? false; + + return ( + + + {showCostCenterAccountColumn && ( + + )} + + + + + + {showCostCenterAccountColumn && ( + + )} + + ); + })} + +
{t('common.date', 'Date')}{t('common.user', 'User')}{t('finance.costCenter', 'Cost center')}{t('finance.transactionType', 'Type')}{t('common.description', 'Description')}{t('finance.amount', 'Amount')}{t('finance.balanceAfter', 'Balance after')}{t('common.actions', 'Actions')}
{formatTimestamp(tx.created_at, i18n.language)}{txUser || t('finance.userWithId', 'User #{{id}}', { id: tx.user_id })}{txCostCenter?.name || '-'} + + {getTransactionTypeLabel(tx.transaction_type)} + {txIsPartialPrint && ( + + {parsed && parsed.partialType + ? t(`finance.partialStatus.${parsed.partialType}`, parsed.partialType) + : t('finance.partial', 'Partial')} + + )} + + {(parsed?.cleanedDescription ?? tx.description) || '-'} + {positive ? '+' : '-'}{currencySymbol}{Math.abs(tx.amount).toFixed(2)} + { + tx.balance_after == null + ? '-' + : `${tx.balance_after < 0 ? '-' : ''}${currencySymbol}${Math.abs(tx.balance_after).toFixed(2)}` + } +
+ + +
+
+
+ )} + + {!txLoading && filteredTransactions.length > 0 && ( +
+
+ + + + {t('finance.pageNumberOf', 'Page {{page}} of {{total}}', { page: txPage, total: txTotalPages })} + + + +
+
+ )} +
+
+ )} + + {showEditTransactionModal && selectedEditTransactionId !== null && ( + setShowEditTransactionModal(false)} + size="md" + > +
+
+ + +
+ +
+ + +
+ +
+ + setEditTransactionAmount(e.target.value)} + placeholder={t('finance.amountExample', 'e.g., 10.50')} + /> +
+ +
+ +