FinancePage.tsx 68 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519
  1. import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
  2. import { Building2, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Clock3, Pencil, Trash2, Wallet } from 'lucide-react';
  3. import { useEffect, useMemo, useState } from 'react';
  4. import { useTranslation } from 'react-i18next';
  5. import { api, type ManualPrintRequest, type TransactionEditRequest, type WalletTransaction } from '../api/client';
  6. import { Button } from '../components/Button';
  7. import { Card, CardContent, CardHeader } from '../components/Card';
  8. import { ConfirmModal } from '../components/ConfirmModal';
  9. import { useAuth } from '../contexts/AuthContext';
  10. import { useToast } from '../contexts/ToastContext';
  11. import { getCurrencySymbol } from '../utils/currency';
  12. import { parseUTCDate } from '../utils/date';
  13. type PartialPrintStatus = 'aborted' | 'failed' | 'cancelled';
  14. interface ParsedPrintChargeDescription {
  15. isPartial: boolean;
  16. partialType: PartialPrintStatus | null;
  17. cleanedDescription: string | null;
  18. }
  19. const PRINT_CHARGE_PARTIAL_REGEX = /\[(aborted|failed|cancelled):\s*([^\]]*)\]/i;
  20. function parsePrintChargeDescription(description: string | null): ParsedPrintChargeDescription {
  21. if (!description) {
  22. return { isPartial: false, partialType: null, cleanedDescription: null };
  23. }
  24. const match = description.match(PRINT_CHARGE_PARTIAL_REGEX);
  25. if (!match) {
  26. return { isPartial: false, partialType: null, cleanedDescription: description };
  27. }
  28. const cleanedDescription = description.replace(match[0], '').trim();
  29. return {
  30. isPartial: true,
  31. partialType: match[1].toLowerCase() as PartialPrintStatus,
  32. cleanedDescription: cleanedDescription || null,
  33. };
  34. }
  35. function formatTimestamp(value: string | null, locale: string): string {
  36. if (!value) return '-';
  37. const parsed = parseUTCDate(value);
  38. if (!parsed) return value;
  39. return parsed.toLocaleString(locale);
  40. }
  41. function parseBudgetValue(raw: string): number | null {
  42. const trimmed = raw.trim();
  43. if (!trimmed) return null;
  44. const parsed = Number.parseFloat(trimmed);
  45. return Number.isFinite(parsed) ? parsed : null;
  46. }
  47. function formatLocalDateTime(value: Date): string {
  48. const year = value.getFullYear();
  49. const month = String(value.getMonth() + 1).padStart(2, '0');
  50. const day = String(value.getDate()).padStart(2, '0');
  51. const hours = String(value.getHours()).padStart(2, '0');
  52. const minutes = String(value.getMinutes()).padStart(2, '0');
  53. return `${year}-${month}-${day}T${hours}:${minutes}`;
  54. }
  55. const fieldClass =
  56. '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';
  57. const labelClass = 'block text-sm font-medium text-white mb-1';
  58. const tableHeadCellClass = 'px-4 py-3 text-left text-bambu-gray font-medium';
  59. const tableCellClass = 'px-4 py-3 align-top text-white';
  60. interface FinanceModalProps {
  61. title: string;
  62. onClose: () => void;
  63. children: React.ReactNode;
  64. size?: 'sm' | 'md' | 'lg';
  65. }
  66. function FinanceModal({ title, onClose, children, size = 'md' }: FinanceModalProps) {
  67. const { t } = useTranslation();
  68. const sizeClass = size === 'sm' ? 'max-w-xl' : size === 'lg' ? 'max-w-6xl' : 'max-w-4xl';
  69. return (
  70. <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4">
  71. <div className={`w-full ${sizeClass} overflow-hidden rounded-lg border border-bambu-dark-tertiary bg-bambu-dark-secondary`}>
  72. <div className="flex items-center justify-between border-b border-bambu-dark-tertiary px-4 py-3">
  73. <h3 className="text-lg font-semibold text-white">{title}</h3>
  74. <Button size="sm" variant="secondary" onClick={onClose}>{t('common.close', 'Close')}</Button>
  75. </div>
  76. <div className="max-h-[75vh] overflow-auto p-4">{children}</div>
  77. </div>
  78. </div>
  79. );
  80. }
  81. export function FinancePage() {
  82. const { t, i18n } = useTranslation();
  83. const { hasPermission, user } = useAuth();
  84. const { showToast } = useToast();
  85. const queryClient = useQueryClient();
  86. const canReadOwn = hasPermission('cost_centers:read_own');
  87. const canReadAllFinance = hasPermission('cost_centers:read_all');
  88. const canCreateCostCenters = hasPermission('cost_centers:create');
  89. const canUpdateCostCenters = hasPermission('cost_centers:modify');
  90. const canUpdateBudgets = hasPermission('cost_centers:modify');
  91. const canAssignCostCenterUsers = hasPermission('cost_centers:modify');
  92. const canAdjustWallet = hasPermission('cost_centers:modify');
  93. // Finance only ever labels a user or picks one to assign, so the slim
  94. // listing suffices -- and a billing operator should not need the admin-level
  95. // users:read (emails, roles, permission sets) to staff a cost center (#1894).
  96. const canReadUsers = hasPermission('users:read_slim') || hasPermission('users:read');
  97. const canAccessAllCostCenters =
  98. canReadAllFinance ||
  99. canCreateCostCenters ||
  100. canUpdateCostCenters ||
  101. canUpdateBudgets ||
  102. canAssignCostCenterUsers ||
  103. canAdjustWallet;
  104. const canAccessFinance = canReadOwn || canAccessAllCostCenters;
  105. const canViewMyCostCenters = canAccessFinance;
  106. const [newCenterName, setNewCenterName] = useState('');
  107. const [newCenterBudgetMode, setNewCenterBudgetMode] = useState<'total' | 'monthly'>('monthly');
  108. const [newCenterBudgetValue, setNewCenterBudgetValue] = useState('');
  109. const [selectedUserId, setSelectedUserId] = useState<number | null>(null);
  110. const [selectedAdjustmentType, setSelectedAdjustmentType] = useState<'deposit' | 'withdraw'>('deposit');
  111. const [adjustmentAmount, setAdjustmentAmount] = useState('');
  112. const [adjustmentDescription, setAdjustmentDescription] = useState('');
  113. const [adjustmentCostCenterId, setAdjustmentCostCenterId] = useState<number | null>(null);
  114. const [selectedManageCenterId, setSelectedManageCenterId] = useState<number | null>(null);
  115. const [memberUserId, setMemberUserId] = useState<number | null>(null);
  116. const [memberCanPrint, setMemberCanPrint] = useState(true);
  117. const [txOffset, setTxOffset] = useState(0);
  118. const txLimit = 50;
  119. const [txTypeFilter, setTxTypeFilter] = useState<string>('all');
  120. const [txCostCenterFilter, setTxCostCenterFilter] = useState<number | 'all'>('all');
  121. const [showCreateCenterModal, setShowCreateCenterModal] = useState(false);
  122. const [showAdjustWalletModal, setShowAdjustWalletModal] = useState(false);
  123. const [showMembersModal, setShowMembersModal] = useState(false);
  124. const [showEditCenterModal, setShowEditCenterModal] = useState(false);
  125. const [financeViewMode, setFinanceViewMode] = useState<'personal' | 'admin'>('personal');
  126. const [selectedEditCenterId, setSelectedEditCenterId] = useState<number | null>(null);
  127. const [editCenterName, setEditCenterName] = useState('');
  128. const [editCenterBudgetMode, setEditCenterBudgetMode] = useState<'total' | 'monthly'>('monthly');
  129. const [editCenterBudgetValue, setEditCenterBudgetValue] = useState('');
  130. const [showEditTransactionModal, setShowEditTransactionModal] = useState(false);
  131. const [selectedEditTransactionId, setSelectedEditTransactionId] = useState<number | null>(null);
  132. const [editTransactionUserId, setEditTransactionUserId] = useState<number | null>(null);
  133. const [editTransactionCostCenterId, setEditTransactionCostCenterId] = useState<number | null>(null);
  134. const [editTransactionAmount, setEditTransactionAmount] = useState('');
  135. const [editTransactionDescription, setEditTransactionDescription] = useState('');
  136. const [showManualPrintModal, setShowManualPrintModal] = useState(false);
  137. const [manualPrintUserId, setManualPrintUserId] = useState<number | null>(null);
  138. const [manualPrintCostCenterId, setManualPrintCostCenterId] = useState<number | null>(null);
  139. const [manualPrintAmount, setManualPrintAmount] = useState('');
  140. const [manualPrintDescription, setManualPrintDescription] = useState('');
  141. const [manualPrintDate, setManualPrintDate] = useState(formatLocalDateTime(new Date()));
  142. const [pendingDeleteCenter, setPendingDeleteCenter] = useState<{ id: number; name: string } | null>(null);
  143. const [pendingDeleteTransactionId, setPendingDeleteTransactionId] = useState<number | null>(null);
  144. const hasAdminFinanceControls =
  145. canReadAllFinance ||
  146. canCreateCostCenters ||
  147. canUpdateCostCenters ||
  148. canUpdateBudgets ||
  149. (canAdjustWallet && canReadUsers) ||
  150. (canAssignCostCenterUsers && canReadUsers);
  151. useEffect(() => {
  152. if (!hasAdminFinanceControls) {
  153. setFinanceViewMode('personal');
  154. }
  155. }, [hasAdminFinanceControls]);
  156. const { data: wallet, isLoading: walletLoading } = useQuery({
  157. queryKey: ['finance', 'me', 'balance'],
  158. queryFn: api.getMyBalance,
  159. enabled: canReadOwn,
  160. });
  161. // Currency comes from the install setting, like every other page. Read from
  162. // /settings/ui-flags rather than /settings, which needs SETTINGS_READ that a
  163. // cost_centers:read_own user does not have (#3023). The Layout already holds
  164. // this query key, so this is served from cache.
  165. const { data: uiFlags } = useQuery({
  166. queryKey: ['ui-flags'],
  167. queryFn: api.getUiFlags,
  168. });
  169. const { data: transactionsResponse, isLoading: personalTxLoading } = useQuery({
  170. queryKey: ['finance', 'me', 'transactions', txLimit, txOffset],
  171. queryFn: () => api.getMyTransactions(txLimit, txOffset),
  172. enabled: canReadOwn,
  173. });
  174. const { data: adminTransactionsResponse, isLoading: adminTxLoading } = useQuery({
  175. queryKey: ['finance', 'transactions', txLimit, txOffset],
  176. queryFn: () => api.getAllTransactions(txLimit, txOffset),
  177. enabled: financeViewMode === 'admin' && canReadAllFinance,
  178. });
  179. const { data: costCenters, isLoading: centersLoading } = useQuery({
  180. queryKey: ['finance', 'cost-centers', financeViewMode],
  181. queryFn: () => (financeViewMode === 'admin' && canAccessAllCostCenters ? api.listCostCenters(true) : api.getMyCostCenters()),
  182. enabled: canViewMyCostCenters,
  183. });
  184. const { data: users } = useQuery({
  185. queryKey: ['users', 'slim'],
  186. queryFn: api.getUsersSlim,
  187. enabled: canReadUsers && (canViewMyCostCenters || canAdjustWallet || canAssignCostCenterUsers),
  188. });
  189. const { data: selectedCenterDetail } = useQuery({
  190. queryKey: ['finance', 'cost-center', selectedManageCenterId],
  191. queryFn: () => api.getCostCenter(selectedManageCenterId!),
  192. enabled: canAssignCostCenterUsers && selectedManageCenterId != null,
  193. });
  194. useEffect(() => {
  195. if (!users || users.length === 0) return;
  196. if (selectedUserId != null && users.some((u) => u.id === selectedUserId)) return;
  197. setSelectedUserId(users[0].id);
  198. }, [users, selectedUserId]);
  199. useEffect(() => {
  200. if (!canAssignCostCenterUsers) return;
  201. const sharedCenters = (costCenters || []).filter((c) => !c.is_private);
  202. if (sharedCenters.length === 0) {
  203. setSelectedManageCenterId(null);
  204. return;
  205. }
  206. if (selectedManageCenterId != null && sharedCenters.some((c) => c.id === selectedManageCenterId)) return;
  207. setSelectedManageCenterId(sharedCenters[0].id);
  208. }, [canAssignCostCenterUsers, costCenters, selectedManageCenterId]);
  209. useEffect(() => {
  210. if (!canAssignCostCenterUsers || !users || users.length === 0) return;
  211. const existingMemberIds = new Set((selectedCenterDetail?.members || []).map((m) => m.user_id));
  212. const firstAvailable = users.find((u) => !existingMemberIds.has(u.id));
  213. setMemberUserId(firstAvailable ? firstAvailable.id : null);
  214. }, [canAssignCostCenterUsers, users, selectedCenterDetail]);
  215. useEffect(() => {
  216. if (!showEditCenterModal) return;
  217. const editableCenters = costCenters || [];
  218. if (editableCenters.length === 0) {
  219. setSelectedEditCenterId(null);
  220. return;
  221. }
  222. if (selectedEditCenterId != null && editableCenters.some((center) => center.id === selectedEditCenterId)) return;
  223. setSelectedEditCenterId(editableCenters[0].id);
  224. }, [showEditCenterModal, selectedEditCenterId, costCenters]);
  225. useEffect(() => {
  226. if (!showEditCenterModal || selectedEditCenterId == null) return;
  227. const center = (costCenters || []).find((entry) => entry.id === selectedEditCenterId);
  228. if (!center) return;
  229. setEditCenterName(center.name);
  230. if (center.budget_mode === 'total') {
  231. setEditCenterBudgetMode('total');
  232. setEditCenterBudgetValue(center.total_budget == null ? '' : String(center.total_budget));
  233. return;
  234. }
  235. setEditCenterBudgetMode('monthly');
  236. setEditCenterBudgetValue(center.monthly_budget == null ? '' : String(center.monthly_budget));
  237. }, [showEditCenterModal, selectedEditCenterId, costCenters]);
  238. const createCostCenterMutation = useMutation({
  239. mutationFn: () =>
  240. api.createCostCenter({
  241. name: newCenterName.trim(),
  242. total_budget: newCenterBudgetMode === 'total' ? parseBudgetValue(newCenterBudgetValue) : null,
  243. monthly_budget: newCenterBudgetMode === 'monthly' ? parseBudgetValue(newCenterBudgetValue) : null,
  244. is_active: true,
  245. }),
  246. onSuccess: () => {
  247. queryClient.invalidateQueries({ queryKey: ['finance'] });
  248. setNewCenterName('');
  249. setNewCenterBudgetValue('');
  250. showToast(t('finance.createdCostCenter', 'Cost center created'));
  251. },
  252. onError: (error: Error) => {
  253. showToast(error.message || t('finance.createCostCenterFailed', 'Failed to create cost center'), 'error');
  254. },
  255. });
  256. const updateBudgetMutation = useMutation({
  257. mutationFn: ({ costCenterId, value, mode }: { costCenterId: number; value: string; mode: 'total' | 'monthly' }) =>
  258. api.updateCostCenterBudgets(costCenterId, {
  259. total_budget: mode === 'total' ? parseBudgetValue(value) : null,
  260. monthly_budget: mode === 'monthly' ? parseBudgetValue(value) : null,
  261. }),
  262. });
  263. const updateCostCenterMutation = useMutation({
  264. mutationFn: ({ costCenterId, name }: { costCenterId: number; name: string }) =>
  265. api.updateCostCenter(costCenterId, {
  266. name,
  267. }),
  268. });
  269. const deleteCostCenterMutation = useMutation({
  270. mutationFn: (costCenterId: number) => api.deleteCostCenter(costCenterId),
  271. });
  272. const deleteTransactionMutation = useMutation({
  273. mutationFn: (transactionId: number) => api.deleteTransaction(transactionId),
  274. onSuccess: () => {
  275. queryClient.invalidateQueries({ queryKey: ['finance'] });
  276. showToast(t('finance.transactionDeleted', 'Transaction deleted'));
  277. },
  278. onError: (error: Error) => {
  279. showToast(error.message || t('finance.deleteTransactionFailed', 'Failed to delete transaction'), 'error');
  280. },
  281. });
  282. const editTransactionMutation = useMutation({
  283. mutationFn: (payload: { transactionId: number; data: TransactionEditRequest }) =>
  284. api.editTransaction(payload.transactionId, payload.data),
  285. onSuccess: () => {
  286. queryClient.invalidateQueries({ queryKey: ['finance'] });
  287. showToast(t('finance.transactionEdited', 'Transaction updated and ledger recalculated'));
  288. setShowEditTransactionModal(false);
  289. },
  290. onError: (error: Error) => {
  291. showToast(error.message || t('finance.editTransactionFailed', 'Failed to edit transaction'), 'error');
  292. },
  293. });
  294. const manualPrintMutation = useMutation({
  295. mutationFn: (data: ManualPrintRequest) => api.createManualPrint(data),
  296. onSuccess: () => {
  297. queryClient.invalidateQueries({ queryKey: ['finance'] });
  298. showToast(t('finance.manualPrintCreated', 'Manual print charge added and ledger recalculated'));
  299. setShowManualPrintModal(false);
  300. setManualPrintUserId(null);
  301. setManualPrintCostCenterId(null);
  302. setManualPrintAmount('');
  303. setManualPrintDescription('');
  304. setManualPrintDate(formatLocalDateTime(new Date()));
  305. },
  306. onError: (error: Error) => {
  307. showToast(error.message || t('finance.manualPrintFailed', 'Failed to create manual print'), 'error');
  308. },
  309. });
  310. const depositMutation = useMutation({
  311. mutationFn: (payload: { userId: number; amount: number; description?: string; costCenterId?: number | null }) =>
  312. api.depositUserBalance(payload.userId, {
  313. amount: payload.amount,
  314. description: payload.description,
  315. cost_center_id: payload.costCenterId ?? null,
  316. }),
  317. });
  318. const withdrawMutation = useMutation({
  319. mutationFn: (payload: { userId: number; amount: number; description?: string; costCenterId?: number | null }) =>
  320. api.withdrawUserBalance(payload.userId, {
  321. amount: payload.amount,
  322. description: payload.description,
  323. cost_center_id: payload.costCenterId ?? null,
  324. }),
  325. });
  326. const upsertMemberMutation = useMutation({
  327. mutationFn: (payload: { costCenterId: number; userId: number; canPrint: boolean }) =>
  328. api.upsertCostCenterMember(payload.costCenterId, {
  329. user_id: payload.userId,
  330. can_print: payload.canPrint,
  331. }),
  332. onSuccess: () => {
  333. queryClient.invalidateQueries({ queryKey: ['finance'] });
  334. showToast(t('finance.memberSaved', 'Member saved'));
  335. },
  336. onError: (error: Error) => {
  337. showToast(error.message || t('finance.memberSaveFailed', 'Failed to save member'), 'error');
  338. },
  339. });
  340. const removeMemberMutation = useMutation({
  341. mutationFn: (payload: { costCenterId: number; userId: number }) =>
  342. api.removeCostCenterMember(payload.costCenterId, payload.userId),
  343. onSuccess: () => {
  344. queryClient.invalidateQueries({ queryKey: ['finance'] });
  345. showToast(t('finance.memberRemoved', 'Member removed'));
  346. },
  347. onError: (error: Error) => {
  348. showToast(error.message || t('finance.memberRemoveFailed', 'Failed to remove member'), 'error');
  349. },
  350. });
  351. const isAdjustingWallet = depositMutation.isPending || withdrawMutation.isPending;
  352. const handleCreateCostCenter = () => {
  353. if (!newCenterName.trim()) {
  354. showToast(t('finance.costCenterNameRequired', 'Cost center name is required'), 'error');
  355. return;
  356. }
  357. createCostCenterMutation.mutate();
  358. };
  359. // A private center is named after its owner and can't be renamed; the backend
  360. // refuses the rename, which used to stop the budget from being saved (#3358).
  361. const selectedEditCenterIsPrivate = (costCenters || []).some(
  362. (c) => c.id === selectedEditCenterId && c.is_private,
  363. );
  364. const handleSaveEditedCenter = async () => {
  365. if (selectedEditCenterId == null) {
  366. showToast(t('finance.selectCostCenter', 'Please select a cost center'), 'error');
  367. return;
  368. }
  369. const name = editCenterName.trim();
  370. if (!name) {
  371. showToast(t('finance.costCenterNameRequired', 'Cost center name is required'), 'error');
  372. return;
  373. }
  374. try {
  375. if (canUpdateCostCenters && !selectedEditCenterIsPrivate) {
  376. await updateCostCenterMutation.mutateAsync({ costCenterId: selectedEditCenterId, name });
  377. }
  378. if (canUpdateBudgets) {
  379. await updateBudgetMutation.mutateAsync({
  380. costCenterId: selectedEditCenterId,
  381. mode: editCenterBudgetMode,
  382. value: editCenterBudgetValue,
  383. });
  384. }
  385. queryClient.invalidateQueries({ queryKey: ['finance'] });
  386. showToast(t('finance.costCenterUpdated', 'Cost center updated'));
  387. setShowEditCenterModal(false);
  388. } catch (error) {
  389. showToast((error as Error).message || t('finance.costCenterUpdateFailed', 'Failed to update cost center'), 'error');
  390. }
  391. };
  392. const handleOpenEditCenter = (costCenterId: number) => {
  393. const center = (costCenters || []).find((entry) => entry.id === costCenterId);
  394. if (!center) return;
  395. setSelectedEditCenterId(center.id);
  396. setEditCenterName(center.name);
  397. if (center.budget_mode === 'total') {
  398. setEditCenterBudgetMode('total');
  399. setEditCenterBudgetValue(center.total_budget == null ? '' : String(center.total_budget));
  400. } else {
  401. setEditCenterBudgetMode('monthly');
  402. setEditCenterBudgetValue(center.monthly_budget == null ? '' : String(center.monthly_budget));
  403. }
  404. setShowEditCenterModal(true);
  405. };
  406. const confirmDeleteCenter = async () => {
  407. if (!pendingDeleteCenter) return;
  408. try {
  409. await deleteCostCenterMutation.mutateAsync(pendingDeleteCenter.id);
  410. queryClient.invalidateQueries({ queryKey: ['finance'] });
  411. if (selectedManageCenterId === pendingDeleteCenter.id) {
  412. setSelectedManageCenterId(null);
  413. }
  414. showToast(t('finance.costCenterDeleted', 'Cost center deleted'));
  415. } catch (error) {
  416. showToast((error as Error).message || t('finance.costCenterDeleteFailed', 'Failed to delete cost center'), 'error');
  417. } finally {
  418. setPendingDeleteCenter(null);
  419. }
  420. };
  421. const confirmDeleteTransaction = () => {
  422. if (pendingDeleteTransactionId == null) return;
  423. deleteTransactionMutation.mutate(pendingDeleteTransactionId, {
  424. onSettled: () => setPendingDeleteTransactionId(null),
  425. });
  426. };
  427. const handleEditTransaction = (tx: WalletTransaction) => {
  428. setSelectedEditTransactionId(tx.id);
  429. setEditTransactionUserId(tx.user_id);
  430. setEditTransactionCostCenterId(tx.cost_center_id);
  431. setEditTransactionAmount(String(tx.amount));
  432. setEditTransactionDescription(tx.description || '');
  433. setShowEditTransactionModal(true);
  434. };
  435. const handleSaveEditTransaction = async () => {
  436. if (selectedEditTransactionId == null) return;
  437. const amount = editTransactionAmount ? Number.parseFloat(editTransactionAmount) : null;
  438. if (amount !== null && !Number.isFinite(amount)) {
  439. showToast(t('finance.amountInvalid', 'Amount must be a valid number'), 'error');
  440. return;
  441. }
  442. editTransactionMutation.mutate({
  443. transactionId: selectedEditTransactionId,
  444. data: {
  445. user_id: editTransactionUserId || undefined,
  446. // null moves it to no cost center; left out, the backend keeps the old one.
  447. cost_center_id: editTransactionCostCenterId || null,
  448. amount: amount ?? undefined,
  449. description: editTransactionDescription || undefined,
  450. },
  451. });
  452. };
  453. const handleSaveManualPrint = async () => {
  454. if (manualPrintUserId == null) {
  455. showToast(t('finance.userRequired', 'Please select a user'), 'error');
  456. return;
  457. }
  458. if (manualPrintCostCenterId == null) {
  459. showToast(t('finance.costCenterRequired', 'Please select a cost center'), 'error');
  460. return;
  461. }
  462. let amount = manualPrintAmount ? Number.parseFloat(manualPrintAmount) : null;
  463. if (amount === null || !Number.isFinite(amount)) {
  464. showToast(t('finance.amountInvalid', 'Amount must be a valid number'), 'error');
  465. return;
  466. }
  467. amount = Math.abs(amount);
  468. manualPrintMutation.mutate({
  469. user_id: manualPrintUserId,
  470. cost_center_id: manualPrintCostCenterId,
  471. amount: amount,
  472. description: manualPrintDescription || undefined,
  473. created_at: manualPrintDate ? new Date(manualPrintDate).toISOString() : undefined,
  474. });
  475. };
  476. const handleWalletAdjustment = async () => {
  477. if (selectedUserId == null) {
  478. showToast(t('finance.userRequired', 'Please select a user'), 'error');
  479. return;
  480. }
  481. const amount = Number.parseFloat(adjustmentAmount);
  482. if (!Number.isFinite(amount) || amount <= 0) {
  483. showToast(t('finance.amountMustBePositive', 'Amount must be greater than zero'), 'error');
  484. return;
  485. }
  486. const payload = {
  487. userId: selectedUserId,
  488. amount,
  489. description: adjustmentDescription.trim() || undefined,
  490. costCenterId: adjustmentCostCenterId,
  491. };
  492. try {
  493. if (selectedAdjustmentType === 'deposit') {
  494. await depositMutation.mutateAsync(payload);
  495. } else {
  496. await withdrawMutation.mutateAsync(payload);
  497. }
  498. queryClient.invalidateQueries({ queryKey: ['finance'] });
  499. setAdjustmentAmount('');
  500. setAdjustmentDescription('');
  501. showToast(
  502. selectedAdjustmentType === 'deposit'
  503. ? t('finance.depositSuccess', 'Deposit successful')
  504. : t('finance.withdrawSuccess', 'Withdrawal successful')
  505. );
  506. } catch (error) {
  507. showToast((error as Error).message || t('finance.adjustmentFailed', 'Wallet adjustment failed'), 'error');
  508. }
  509. };
  510. const handleAddMember = () => {
  511. if (selectedManageCenterId == null) {
  512. showToast(t('finance.selectCostCenter', 'Please select a cost center'), 'error');
  513. return;
  514. }
  515. if (memberUserId == null) {
  516. showToast(t('finance.noEligibleUsers', 'No eligible users available'), 'error');
  517. return;
  518. }
  519. upsertMemberMutation.mutate({
  520. costCenterId: selectedManageCenterId,
  521. userId: memberUserId,
  522. canPrint: memberCanPrint,
  523. });
  524. };
  525. const handleRemoveMember = (userId: number) => {
  526. if (selectedManageCenterId == null) return;
  527. removeMemberMutation.mutate({ costCenterId: selectedManageCenterId, userId });
  528. };
  529. const currency = uiFlags?.currency || 'USD';
  530. const currencySymbol = getCurrencySymbol(currency);
  531. const sortedUsers = useMemo(() => {
  532. return [...(users || [])].sort((a, b) => a.username.localeCompare(b.username));
  533. }, [users]);
  534. const usersById = useMemo(() => {
  535. const map = new Map<number, string>();
  536. for (const entry of sortedUsers) {
  537. map.set(entry.id, entry.username);
  538. }
  539. return map;
  540. }, [sortedUsers]);
  541. const availableUsersForCenter = useMemo(() => {
  542. const existingIds = new Set((selectedCenterDetail?.members || []).map((m) => m.user_id));
  543. return sortedUsers.filter((u) => !existingIds.has(u.id));
  544. }, [sortedUsers, selectedCenterDetail]);
  545. const activeTransactionsResponse =
  546. financeViewMode === 'admin' && canReadAllFinance
  547. ? adminTransactionsResponse
  548. : transactionsResponse;
  549. const txLoading = (financeViewMode === 'admin' && canReadAllFinance)
  550. ? adminTxLoading
  551. : personalTxLoading;
  552. const transactions = useMemo(
  553. () => activeTransactionsResponse?.items ?? [],
  554. [activeTransactionsResponse?.items]
  555. );
  556. const txTotal = activeTransactionsResponse?.total ?? 0;
  557. const txTotalPages = Math.max(1, Math.ceil(txTotal / txLimit));
  558. const filteredTransactions = useMemo(() => {
  559. const items = transactions;
  560. return items.filter((tx) => {
  561. if (txTypeFilter !== 'all' && tx.transaction_type !== txTypeFilter) return false;
  562. if (txCostCenterFilter !== 'all' && tx.cost_center_id !== txCostCenterFilter) return false;
  563. return true;
  564. });
  565. }, [transactions, txTypeFilter, txCostCenterFilter]);
  566. const txPage = Math.floor(txOffset / txLimit) + 1;
  567. const showCostCenterAccountColumn = financeViewMode === 'admin';
  568. const getTransactionTypeLabel = (transactionType: string): string => {
  569. if (transactionType === 'deposit') return t('finance.deposit');
  570. if (transactionType === 'withdraw') return t('finance.withdraw');
  571. if (transactionType === 'print_charge') return t('finance.printCharge');
  572. return transactionType;
  573. };
  574. const getPrivateOwnerLabel = (ownerUserId: number | null): string => {
  575. // Private centres are always created with an owner; none means they were deleted.
  576. if (ownerUserId == null) return t('finance.deletedUser', 'Deleted user');
  577. const ownerName = usersById.get(ownerUserId);
  578. if (ownerName) return ownerName;
  579. if (user?.id === ownerUserId && user.username) return user.username;
  580. return t('finance.userWithId', 'User #{{id}}', { id: ownerUserId });
  581. };
  582. const formatBudgetProgress = (center: { budget_available: number | null; budget_limit: number | null }) => {
  583. if (center.budget_limit == null || center.budget_available == null) return t('finance.unlimited', 'Unlimited');
  584. return `${currencySymbol}${center.budget_available.toFixed(2)}/${currencySymbol}${center.budget_limit.toFixed(2)}`;
  585. };
  586. if (!canAccessFinance) {
  587. return (
  588. <div className="p-4 md:p-8 space-y-6">
  589. <div className="flex items-center gap-2">
  590. <Wallet className="h-6 w-6 text-bambu-green" />
  591. <h1 className="text-2xl font-bold text-white">{t('finance.title', 'Finance')}</h1>
  592. </div>
  593. <div>
  594. <p className="text-bambu-gray mt-2 max-w-2xl">{t('finance.subtitle', 'Wallet, personal transactions, and cost centers')}</p>
  595. </div>
  596. <Card>
  597. <CardContent className="py-8 text-center text-bambu-gray">
  598. {t('finance.noAccess', 'You do not have permission to view finance data.')}
  599. </CardContent>
  600. </Card>
  601. </div>
  602. );
  603. }
  604. return (
  605. <div className="p-4 md:p-8 space-y-8">
  606. <div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
  607. <div>
  608. <div className="flex items-center gap-2">
  609. <Wallet className="h-6 w-6 text-bambu-green" />
  610. <h1 className="text-2xl font-bold text-white">{t('finance.title', 'Finance')}</h1>
  611. </div>
  612. <p className="text-bambu-gray mt-2 max-w-2xl">{t('finance.subtitle', 'Wallet, personal transactions, and cost centers')}</p>
  613. <p className="text-xs text-bambu-gray mt-1 max-w-2xl">
  614. {t('finance.budgetPolicyHint', 'Account balances track costs. Printing is limited only by the selected cost center budget; without a budget, printing is unlimited.')}
  615. </p>
  616. </div>
  617. <div className="flex flex-col gap-2 lg:items-end">
  618. {hasAdminFinanceControls && (
  619. <div className="inline-flex overflow-hidden rounded border border-bambu-dark-tertiary">
  620. <button
  621. type="button"
  622. onClick={() => setFinanceViewMode('personal')}
  623. className={`px-3 py-1.5 text-sm ${financeViewMode === 'personal' ? 'bg-bambu-green text-black font-medium' : 'bg-bambu-dark text-bambu-gray'}`}
  624. >
  625. {t('finance.personalView', 'Personal view')}
  626. </button>
  627. <button
  628. type="button"
  629. onClick={() => setFinanceViewMode('admin')}
  630. className={`px-3 py-1.5 text-sm ${financeViewMode === 'admin' ? 'bg-bambu-green text-black font-medium' : 'bg-bambu-dark text-bambu-gray'}`}
  631. >
  632. {t('finance.adminView', 'Admin view')}
  633. </button>
  634. </div>
  635. )}
  636. {financeViewMode === 'admin' && hasAdminFinanceControls && (
  637. <div className="flex flex-wrap gap-2 lg:justify-end">
  638. {canCreateCostCenters && (
  639. <Button size="sm" variant="secondary" onClick={() => setShowCreateCenterModal(true)}>
  640. {t('finance.createCostCenter', 'Create cost center')}
  641. </Button>
  642. )}
  643. {canAdjustWallet && canReadUsers && (
  644. <Button size="sm" variant="secondary" onClick={() => setShowAdjustWalletModal(true)}>
  645. {t('finance.adjustWallet', 'Adjust wallet')}
  646. </Button>
  647. )}
  648. {canAdjustWallet && canReadUsers && (
  649. <Button size="sm" variant="secondary" onClick={() => setShowManualPrintModal(true)}>
  650. {t('finance.addManualPrint', 'Add manual print')}
  651. </Button>
  652. )}
  653. {canAssignCostCenterUsers && canReadUsers && (
  654. <Button size="sm" variant="secondary" onClick={() => setShowMembersModal(true)}>
  655. {t('finance.manageMembers', 'Manage cost center members')}
  656. </Button>
  657. )}
  658. </div>
  659. )}
  660. </div>
  661. </div>
  662. <div className="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
  663. <Card>
  664. <CardHeader className="flex flex-row items-center justify-between pb-2">
  665. <span className="text-sm text-bambu-gray">{t('finance.currentBalance', 'Personal account balance')}</span>
  666. <Wallet className="w-4 h-4 text-bambu-green/90" />
  667. </CardHeader>
  668. <CardContent className="pt-1">
  669. <p className="text-3xl font-semibold text-white leading-tight">
  670. {walletLoading ? t('common.loading', 'Loading...') : `${currencySymbol}${(wallet?.balance ?? 0).toFixed(2)}`}
  671. </p>
  672. </CardContent>
  673. </Card>
  674. <Card>
  675. <CardHeader className="flex flex-row items-center justify-between pb-2">
  676. <span className="text-sm text-bambu-gray">
  677. {financeViewMode === 'admin'
  678. ? t('finance.transactions', 'Transactions')
  679. : t('finance.personalTransactions', 'Personal transactions')}
  680. </span>
  681. <Clock3 className="w-4 h-4 text-blue-400" />
  682. </CardHeader>
  683. <CardContent className="pt-1">
  684. <p className="text-3xl font-semibold text-white leading-tight">{txLoading ? '-' : txTotal}</p>
  685. </CardContent>
  686. </Card>
  687. <Card>
  688. <CardHeader className="flex flex-row items-center justify-between pb-2">
  689. <span className="text-sm text-bambu-gray">{t('finance.costCenters', 'Cost centers')}</span>
  690. <Building2 className="w-4 h-4 text-orange-400" />
  691. </CardHeader>
  692. <CardContent className="pt-1">
  693. <p className="text-3xl font-semibold text-white leading-tight">{centersLoading ? '-' : costCenters?.length ?? 0}</p>
  694. </CardContent>
  695. </Card>
  696. </div>
  697. {showCreateCenterModal && canCreateCostCenters && (
  698. <FinanceModal title={t('finance.createCostCenter', 'Create cost center')} size="md" onClose={() => setShowCreateCenterModal(false)}>
  699. <div className="space-y-4">
  700. <div className="grid gap-4 md:grid-cols-3">
  701. <div>
  702. <label className={labelClass}>{t('finance.costCenterName', 'Name')}</label>
  703. <input
  704. type="text"
  705. value={newCenterName}
  706. onChange={(e) => setNewCenterName(e.target.value)}
  707. placeholder={t('finance.costCenterName', 'Name')}
  708. className={fieldClass}
  709. />
  710. </div>
  711. <div>
  712. <label className={labelClass}>{t('finance.budgetType', 'Budget type')}</label>
  713. <select
  714. value={newCenterBudgetMode}
  715. onChange={(e) => setNewCenterBudgetMode(e.target.value as 'total' | 'monthly')}
  716. className={fieldClass}
  717. >
  718. <option value="monthly">{t('finance.monthlyBudget', 'Monthly budget')}</option>
  719. <option value="total">{t('finance.totalBudget', 'Total budget')}</option>
  720. </select>
  721. </div>
  722. <div>
  723. <label className={labelClass}>
  724. {newCenterBudgetMode === 'monthly' ? t('finance.monthlyBudget', 'Monthly budget') : t('finance.totalBudget', 'Total budget')}
  725. </label>
  726. <input
  727. type="number"
  728. step="0.01"
  729. value={newCenterBudgetValue}
  730. onChange={(e) => setNewCenterBudgetValue(e.target.value)}
  731. placeholder="0.00"
  732. className={fieldClass}
  733. />
  734. </div>
  735. </div>
  736. <Button className="min-w-[180px]" onClick={handleCreateCostCenter} disabled={createCostCenterMutation.isPending}>
  737. {createCostCenterMutation.isPending ? t('common.saving', 'Saving...') : t('finance.create', 'Create')}
  738. </Button>
  739. </div>
  740. </FinanceModal>
  741. )}
  742. {showAdjustWalletModal && canAdjustWallet && canReadUsers && (
  743. <FinanceModal title={t('finance.adjustWallet', 'Adjust wallet')} size="md" onClose={() => setShowAdjustWalletModal(false)}>
  744. <div className="space-y-4">
  745. <div className="grid gap-4 md:grid-cols-2">
  746. <div>
  747. <label className={labelClass}>{t('finance.selectUser', 'Select user')}</label>
  748. <select
  749. value={selectedUserId ?? ''}
  750. onChange={(e) => setSelectedUserId(e.target.value ? Number(e.target.value) : null)}
  751. className={fieldClass}
  752. >
  753. {(sortedUsers || []).map((u) => (
  754. <option key={u.id} value={u.id}>{u.username}</option>
  755. ))}
  756. </select>
  757. </div>
  758. <div>
  759. <label className={labelClass}>{t('finance.transactionType', 'Type')}</label>
  760. <select
  761. value={selectedAdjustmentType}
  762. onChange={(e) => setSelectedAdjustmentType(e.target.value as 'deposit' | 'withdraw')}
  763. className={fieldClass}
  764. >
  765. <option value="deposit">{t('finance.deposit', 'Deposit')}</option>
  766. <option value="withdraw">{t('finance.withdraw', 'Withdraw')}</option>
  767. </select>
  768. </div>
  769. <div>
  770. <label className={labelClass}>{t('finance.amount', 'Amount')}</label>
  771. <input
  772. type="number"
  773. step="0.01"
  774. min="0"
  775. value={adjustmentAmount}
  776. onChange={(e) => setAdjustmentAmount(e.target.value)}
  777. placeholder="0.00"
  778. className={fieldClass}
  779. />
  780. </div>
  781. <div>
  782. <label className={labelClass}>{t('finance.costCenters', 'Cost centers')}</label>
  783. <select
  784. value={adjustmentCostCenterId ?? ''}
  785. onChange={(e) => setAdjustmentCostCenterId(e.target.value ? Number(e.target.value) : null)}
  786. className={fieldClass}
  787. >
  788. <option value="">{t('finance.noCostCenter', 'No cost center')}</option>
  789. {(costCenters || []).map((center) => (
  790. <option key={center.id} value={center.id}>{center.name}</option>
  791. ))}
  792. </select>
  793. </div>
  794. </div>
  795. <div>
  796. <label className={labelClass}>{t('common.description', 'Description')}</label>
  797. <input
  798. type="text"
  799. value={adjustmentDescription}
  800. onChange={(e) => setAdjustmentDescription(e.target.value)}
  801. placeholder={t('finance.descriptionOptional', 'Description (optional)')}
  802. className={fieldClass}
  803. />
  804. </div>
  805. <Button className="min-w-[180px]" onClick={handleWalletAdjustment} disabled={isAdjustingWallet}>
  806. {isAdjustingWallet ? t('common.saving', 'Saving...') : t('finance.applyAdjustment', 'Apply adjustment')}
  807. </Button>
  808. </div>
  809. </FinanceModal>
  810. )}
  811. {showMembersModal && canAssignCostCenterUsers && canReadUsers && (
  812. <FinanceModal title={t('finance.manageMembers', 'Manage cost center members')} size="lg" onClose={() => setShowMembersModal(false)}>
  813. <div className="space-y-5">
  814. <div className="grid gap-5 md:grid-cols-2">
  815. <div>
  816. <label className={labelClass}>{t('finance.costCenters', 'Cost centers')}</label>
  817. <select
  818. value={selectedManageCenterId ?? ''}
  819. onChange={(e) => setSelectedManageCenterId(e.target.value ? Number(e.target.value) : null)}
  820. className={fieldClass}
  821. >
  822. {(costCenters || [])
  823. .filter((center) => !center.is_private)
  824. .map((center) => (
  825. <option key={center.id} value={center.id}>{center.name}</option>
  826. ))}
  827. </select>
  828. </div>
  829. <div>
  830. <label className={labelClass}>{t('finance.selectUser', 'Select user')}</label>
  831. <select
  832. value={memberUserId ?? ''}
  833. onChange={(e) => setMemberUserId(e.target.value ? Number(e.target.value) : null)}
  834. className={fieldClass}
  835. >
  836. <option value="">{t('finance.selectUser', 'Select user')}</option>
  837. {availableUsersForCenter.map((u) => (
  838. <option key={u.id} value={u.id}>{u.username}</option>
  839. ))}
  840. </select>
  841. </div>
  842. <div className="flex items-center gap-2 rounded border border-bambu-dark-tertiary bg-bambu-dark-secondary px-3 py-2">
  843. <input
  844. id="memberCanPrint"
  845. type="checkbox"
  846. checked={memberCanPrint}
  847. onChange={(e) => setMemberCanPrint(e.target.checked)}
  848. className="rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
  849. />
  850. <label htmlFor="memberCanPrint" className="text-sm text-bambu-gray">
  851. {t('finance.memberCanPrint', 'Member can print')}
  852. </label>
  853. </div>
  854. <div className="flex items-end">
  855. <Button className="min-w-[180px]" onClick={handleAddMember} disabled={upsertMemberMutation.isPending || memberUserId == null}>
  856. {upsertMemberMutation.isPending ? t('common.saving', 'Saving...') : t('finance.addMember', 'Add member')}
  857. </Button>
  858. </div>
  859. </div>
  860. <div className="overflow-auto rounded-lg border border-bambu-dark-tertiary">
  861. <table className="w-full text-sm">
  862. <thead>
  863. <tr className="border-b border-bambu-dark-tertiary bg-bambu-dark text-bambu-gray">
  864. <th className={tableHeadCellClass}>{t('common.name', 'Name')}</th>
  865. <th className={tableHeadCellClass}>{t('finance.canPrint', 'Can print')}</th>
  866. <th className={tableHeadCellClass}>{t('common.actions', 'Actions')}</th>
  867. </tr>
  868. </thead>
  869. <tbody>
  870. {(selectedCenterDetail?.members || []).map((member) => {
  871. const user = sortedUsers.find((u) => u.id === member.user_id);
  872. return (
  873. <tr key={member.id} className="border-b border-bambu-dark-tertiary/60 text-white">
  874. <td className={tableCellClass}>{user?.username || `User ${member.user_id}`}</td>
  875. <td className={tableCellClass}>{member.can_print ? t('common.yes', 'Yes') : t('common.no', 'No')}</td>
  876. <td className={tableCellClass}>
  877. <Button
  878. size="sm"
  879. variant="danger"
  880. onClick={() => handleRemoveMember(member.user_id)}
  881. disabled={removeMemberMutation.isPending}
  882. >
  883. {t('common.remove', 'Remove')}
  884. </Button>
  885. </td>
  886. </tr>
  887. );
  888. })}
  889. {(selectedCenterDetail?.members || []).length === 0 && (
  890. <tr>
  891. <td colSpan={3} className="py-3 text-bambu-gray">
  892. {t('finance.noMembers', 'No members assigned.')}
  893. </td>
  894. </tr>
  895. )}
  896. </tbody>
  897. </table>
  898. </div>
  899. </div>
  900. </FinanceModal>
  901. )}
  902. {showEditCenterModal && (canUpdateCostCenters || canUpdateBudgets) && (
  903. <FinanceModal title={t('finance.editCostCenter', 'Edit cost center')} size="md" onClose={() => setShowEditCenterModal(false)}>
  904. <div className="space-y-4">
  905. <div>
  906. <label className={labelClass}>{t('finance.costCenterName', 'Name')}</label>
  907. <input
  908. type="text"
  909. value={editCenterName}
  910. onChange={(e) => setEditCenterName(e.target.value)}
  911. placeholder={t('finance.costCenterName', 'Name')}
  912. className={fieldClass}
  913. disabled={!canUpdateCostCenters || selectedEditCenterIsPrivate}
  914. />
  915. </div>
  916. <div className="grid gap-4 md:grid-cols-2">
  917. <div>
  918. <label className={labelClass}>{t('finance.budgetType', 'Budget type')}</label>
  919. <select
  920. value={editCenterBudgetMode}
  921. onChange={(e) => setEditCenterBudgetMode(e.target.value as 'total' | 'monthly')}
  922. className={fieldClass}
  923. disabled={!canUpdateBudgets}
  924. >
  925. <option value="monthly">{t('finance.monthlyBudget', 'Monthly budget')}</option>
  926. <option value="total">{t('finance.totalBudget', 'Total budget')}</option>
  927. </select>
  928. </div>
  929. <div>
  930. <label className={labelClass}>
  931. {editCenterBudgetMode === 'monthly' ? t('finance.monthlyBudget', 'Monthly budget') : t('finance.totalBudget', 'Total budget')}
  932. </label>
  933. <input
  934. type="number"
  935. step="0.01"
  936. value={editCenterBudgetValue}
  937. onChange={(e) => setEditCenterBudgetValue(e.target.value)}
  938. placeholder="0.00"
  939. className={fieldClass}
  940. disabled={!canUpdateBudgets}
  941. />
  942. </div>
  943. </div>
  944. <Button
  945. className="min-w-[180px]"
  946. onClick={handleSaveEditedCenter}
  947. disabled={updateCostCenterMutation.isPending || updateBudgetMutation.isPending || selectedEditCenterId == null}
  948. >
  949. {(updateCostCenterMutation.isPending || updateBudgetMutation.isPending)
  950. ? t('common.saving', 'Saving...')
  951. : t('common.save', 'Save')}
  952. </Button>
  953. </div>
  954. </FinanceModal>
  955. )}
  956. <div className={`grid gap-6 ${canViewMyCostCenters && canReadOwn ? 'xl:grid-cols-2' : ''}`}>
  957. {canViewMyCostCenters && (
  958. <Card>
  959. <CardHeader>
  960. <h2 className="text-lg font-semibold text-white">{t('finance.myCostCenters', 'My cost centers')}</h2>
  961. <p className="text-sm text-bambu-gray mt-1">{t('finance.costCentersHint', 'Review budget limits and keep costs under control')}</p>
  962. </CardHeader>
  963. <CardContent className="space-y-3">
  964. {centersLoading && <p className="text-sm text-bambu-gray">{t('common.loading', 'Loading...')}</p>}
  965. {!centersLoading && (!costCenters || costCenters.length === 0) && (
  966. <p className="text-sm text-bambu-gray">{t('finance.noCostCenters', 'No cost centers found.')}</p>
  967. )}
  968. {!centersLoading && costCenters && costCenters.length > 0 && (
  969. <div className="overflow-auto rounded-lg border border-bambu-dark-tertiary">
  970. <table className="w-full text-sm">
  971. <thead>
  972. <tr className="border-b border-bambu-dark-tertiary bg-bambu-dark text-bambu-gray">
  973. <th className={tableHeadCellClass}>{t('common.name', 'Name')}</th>
  974. {showCostCenterAccountColumn && <th className={tableHeadCellClass}>{t('finance.owner', 'Owner')}</th>}
  975. <th className={tableHeadCellClass}>{t('finance.balance', 'Account balance')}</th>
  976. <th className={tableHeadCellClass}>{t('finance.budget', 'Budget')}</th>
  977. {showCostCenterAccountColumn && <th className={tableHeadCellClass}>{t('common.actions', 'Actions')}</th>}
  978. </tr>
  979. </thead>
  980. <tbody>
  981. {costCenters.map((center) => {
  982. const canEditRow = canUpdateCostCenters || canUpdateBudgets;
  983. const canDeleteRow = !center.is_private && canUpdateCostCenters;
  984. return (
  985. <tr key={center.id} className="border-b border-bambu-dark-tertiary/60 text-white">
  986. <td className={tableCellClass}>{center.name}</td>
  987. {showCostCenterAccountColumn && (
  988. <td className={tableCellClass}>{center.is_private ? getPrivateOwnerLabel(center.owner_user_id) : t('finance.shared', 'Shared')}</td>
  989. )}
  990. <td className={tableCellClass}>{currencySymbol}{center.total_balance.toFixed(2)}</td>
  991. <td className={tableCellClass}>
  992. <div className="flex flex-col gap-0.5">
  993. <span>{formatBudgetProgress(center)}</span>
  994. <span className="text-xs text-bambu-gray">
  995. {center.budget_mode === 'monthly'
  996. ? t('finance.monthlyBudget', 'Monthly budget')
  997. : center.budget_mode === 'total'
  998. ? t('finance.totalBudget', 'Total budget')
  999. : t('finance.noBudget', 'No budget')}
  1000. </span>
  1001. </div>
  1002. </td>
  1003. {showCostCenterAccountColumn && (
  1004. <td className={tableCellClass}>
  1005. <div className="flex items-center gap-2">
  1006. <Button
  1007. size="sm"
  1008. variant="ghost"
  1009. onClick={() => handleOpenEditCenter(center.id)}
  1010. disabled={!canEditRow}
  1011. title={canEditRow ? t('common.edit', 'Edit') : t('finance.cannotEditPrivateCostCenter', 'Private cost centers cannot be edited here')}
  1012. className="p-1.5 sm:p-2"
  1013. >
  1014. <Pencil className="w-4 h-4" />
  1015. </Button>
  1016. {canDeleteRow && (
  1017. <Button
  1018. size="sm"
  1019. variant="ghost"
  1020. onClick={() => setPendingDeleteCenter({ id: center.id, name: center.name })}
  1021. disabled={deleteCostCenterMutation.isPending}
  1022. title={t('common.delete', 'Delete')}
  1023. className="text-red-400 hover:text-red-300 hover:bg-red-500/10 p-1.5 sm:p-2"
  1024. >
  1025. <Trash2 className="w-4 h-4" />
  1026. </Button>
  1027. )}
  1028. </div>
  1029. </td>
  1030. )}
  1031. </tr>
  1032. );
  1033. })}
  1034. </tbody>
  1035. </table>
  1036. </div>
  1037. )}
  1038. </CardContent>
  1039. </Card>
  1040. )}
  1041. {canReadOwn && (
  1042. <Card>
  1043. <CardHeader>
  1044. <h2 className="text-lg font-semibold text-white">{t('finance.recentTransactions', 'Recent transactions')}</h2>
  1045. <p className="text-sm text-bambu-gray mt-1">{t('finance.transactionsHint', 'Filter by type and cost center, then navigate pages')}</p>
  1046. </CardHeader>
  1047. <CardContent className="space-y-4">
  1048. <div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] xl:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_minmax(0,1fr)_auto] items-end">
  1049. <div>
  1050. <label className={labelClass}>{t('finance.transactionType', 'Type')}</label>
  1051. <select
  1052. value={txTypeFilter}
  1053. onChange={(e) => setTxTypeFilter(e.target.value)}
  1054. className={fieldClass}
  1055. >
  1056. <option value="all">{t('finance.allTypes')}</option>
  1057. <option value="deposit">{t('finance.deposit')}</option>
  1058. <option value="withdraw">{t('finance.withdraw')}</option>
  1059. <option value="print_charge">{t('finance.printCharge')}</option>
  1060. </select>
  1061. </div>
  1062. <div>
  1063. <label className={labelClass}>{t('finance.costCenters', 'Cost centers')}</label>
  1064. <select
  1065. value={txCostCenterFilter}
  1066. onChange={(e) => setTxCostCenterFilter(e.target.value === 'all' ? 'all' : Number(e.target.value))}
  1067. className={fieldClass}
  1068. >
  1069. <option value="all">{t('finance.allCostCenters')}</option>
  1070. {(costCenters || []).map((center) => (
  1071. <option key={center.id} value={center.id}>{center.name}</option>
  1072. ))}
  1073. </select>
  1074. </div>
  1075. </div>
  1076. {txLoading && <p className="text-sm text-bambu-gray">{t('common.loading', 'Loading...')}</p>}
  1077. {!txLoading && (!transactions || transactions.length === 0) && (
  1078. <p className="text-sm text-bambu-gray">{t('finance.noTransactions', 'No transactions available.')}</p>
  1079. )}
  1080. {!txLoading && transactions.length > 0 && filteredTransactions.length === 0 && (
  1081. <p className="text-sm text-bambu-gray">{t('finance.noTransactionsForFilter', 'No transactions match the selected filters.')}</p>
  1082. )}
  1083. {!txLoading && filteredTransactions.length > 0 && (
  1084. <div className="overflow-auto rounded-lg border border-bambu-dark-tertiary">
  1085. <table className="w-full text-sm">
  1086. <thead>
  1087. <tr className="border-b border-bambu-dark-tertiary bg-bambu-dark text-bambu-gray">
  1088. <th className={tableHeadCellClass}>{t('common.date', 'Date')}</th>
  1089. {showCostCenterAccountColumn && <th className={tableHeadCellClass}>{t('common.user', 'User')}</th>}
  1090. <th className={tableHeadCellClass}>{t('finance.costCenter', 'Cost center')}</th>
  1091. <th className={tableHeadCellClass}>{t('finance.transactionType', 'Type')}</th>
  1092. <th className={tableHeadCellClass}>{t('common.description', 'Description')}</th>
  1093. <th className={tableHeadCellClass}>{t('finance.amount', 'Amount')}</th>
  1094. <th className={tableHeadCellClass}>{t('finance.balanceAfter', 'Balance after')}</th>
  1095. {showCostCenterAccountColumn && <th className={tableHeadCellClass}>{t('common.actions', 'Actions')}</th>}
  1096. </tr>
  1097. </thead>
  1098. <tbody>
  1099. {filteredTransactions.map((tx) => {
  1100. const positive = tx.amount >= 0;
  1101. const txCostCenter = tx.cost_center_id == null
  1102. ? null
  1103. : (costCenters || []).find((center) => center.id === tx.cost_center_id);
  1104. // No user: deleted since, or with auth off never had one.
  1105. const txUser = tx.user_id == null
  1106. ? t('finance.noUser', 'No user')
  1107. : usersById.get(tx.user_id) || t('finance.userWithId', 'User #{{id}}', { id: tx.user_id });
  1108. const parsed = tx.transaction_type === 'print_charge'
  1109. ? parsePrintChargeDescription((tx as { description: string | null }).description ?? null)
  1110. : null;
  1111. const txIsPartialPrint = parsed?.isPartial ?? false;
  1112. return (
  1113. <tr key={tx.id} className="border-b border-bambu-dark-tertiary/60 text-white">
  1114. <td className={tableCellClass}>{formatTimestamp(tx.created_at, i18n.language)}</td>
  1115. {showCostCenterAccountColumn && (
  1116. <td className={tableCellClass}>{txUser}</td>
  1117. )}
  1118. <td className={tableCellClass}>{txCostCenter?.name || '-'}</td>
  1119. <td className={tableCellClass}>
  1120. <span className="inline-flex items-center gap-2">
  1121. <span>{getTransactionTypeLabel(tx.transaction_type)}</span>
  1122. {txIsPartialPrint && (
  1123. <span className="rounded border border-yellow-500/40 bg-yellow-500/10 px-2 py-0.5 text-[11px] text-yellow-300">
  1124. {parsed && parsed.partialType
  1125. ? t(`finance.partialStatus.${parsed.partialType}`, parsed.partialType)
  1126. : t('finance.partial', 'Partial')}
  1127. </span>
  1128. )}
  1129. </span>
  1130. </td>
  1131. <td className={tableCellClass}>{(parsed?.cleanedDescription ?? tx.description) || '-'}</td>
  1132. <td className={`${tableCellClass} ${positive ? 'text-green-400' : 'text-red-400'}`}>
  1133. {positive ? '+' : '-'}{currencySymbol}{Math.abs(tx.amount).toFixed(2)}
  1134. </td>
  1135. <td className={tableCellClass}>{
  1136. tx.balance_after == null
  1137. ? '-'
  1138. : `${tx.balance_after < 0 ? '-' : ''}${currencySymbol}${Math.abs(tx.balance_after).toFixed(2)}`
  1139. }</td>
  1140. {showCostCenterAccountColumn && (
  1141. <td className={tableCellClass}>
  1142. <div className="flex items-center gap-2">
  1143. <Button
  1144. size="sm"
  1145. variant="secondary"
  1146. onClick={() => handleEditTransaction(tx)}
  1147. disabled={editTransactionMutation.isPending}
  1148. >
  1149. <Pencil className="w-4 h-4" />
  1150. </Button>
  1151. <Button
  1152. size="sm"
  1153. variant="danger"
  1154. onClick={() => setPendingDeleteTransactionId(tx.id)}
  1155. disabled={deleteTransactionMutation.isPending}
  1156. >
  1157. <Trash2 className="w-4 h-4" />
  1158. </Button>
  1159. </div>
  1160. </td>
  1161. )}
  1162. </tr>
  1163. );
  1164. })}
  1165. </tbody>
  1166. </table>
  1167. </div>
  1168. )}
  1169. {!txLoading && filteredTransactions.length > 0 && (
  1170. <div className="flex justify-end pt-2 lg:pt-0">
  1171. <div className="flex flex-wrap items-center justify-end gap-2">
  1172. <button
  1173. type="button"
  1174. onClick={() => setTxOffset(0)}
  1175. disabled={txOffset === 0 || txLoading}
  1176. className="p-1.5 rounded text-bambu-gray hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
  1177. aria-label={t('finance.first', 'First')}
  1178. >
  1179. <ChevronsLeft className="w-4 h-4" />
  1180. </button>
  1181. <button
  1182. type="button"
  1183. onClick={() => setTxOffset((prev) => Math.max(0, prev - txLimit))}
  1184. disabled={txOffset === 0 || txLoading}
  1185. className="p-1.5 rounded text-bambu-gray hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
  1186. aria-label={t('finance.prev', 'Previous')}
  1187. >
  1188. <ChevronLeft className="w-4 h-4" />
  1189. </button>
  1190. <span className="text-bambu-gray px-1 whitespace-nowrap">
  1191. {t('finance.pageNumberOf', 'Page {{page}} of {{total}}', { page: txPage, total: txTotalPages })}
  1192. </span>
  1193. <button
  1194. type="button"
  1195. onClick={() => setTxOffset((prev) => prev + txLimit)}
  1196. disabled={txLoading || txPage >= txTotalPages}
  1197. className="p-1.5 rounded text-bambu-gray hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
  1198. aria-label={t('finance.next', 'Next')}
  1199. >
  1200. <ChevronRight className="w-4 h-4" />
  1201. </button>
  1202. <button
  1203. type="button"
  1204. onClick={() => setTxOffset(Math.max(0, (txTotalPages - 1) * txLimit))}
  1205. disabled={txLoading || txPage >= txTotalPages}
  1206. className="p-1.5 rounded text-bambu-gray hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
  1207. aria-label={t('finance.last', 'Last')}
  1208. >
  1209. <ChevronsRight className="w-4 h-4" />
  1210. </button>
  1211. </div>
  1212. </div>
  1213. )}
  1214. </CardContent>
  1215. </Card>
  1216. )}
  1217. {showEditTransactionModal && selectedEditTransactionId !== null && (
  1218. <FinanceModal
  1219. title={t('finance.editTransaction', 'Edit Transaction')}
  1220. onClose={() => setShowEditTransactionModal(false)}
  1221. size="md"
  1222. >
  1223. <div className="space-y-4">
  1224. <div>
  1225. <label className={labelClass}>{t('common.user', 'User')}</label>
  1226. <select
  1227. className={fieldClass}
  1228. value={editTransactionUserId || ''}
  1229. onChange={(e) => setEditTransactionUserId(e.target.value ? Number(e.target.value) : null)}
  1230. >
  1231. <option value="">{t('finance.selectUser', 'Select user...')}</option>
  1232. {sortedUsers.map((u) => (
  1233. <option key={u.id} value={u.id}>{u.username}</option>
  1234. ))}
  1235. </select>
  1236. </div>
  1237. <div>
  1238. <label className={labelClass}>{t('finance.costCenter', 'Cost center (optional)')}</label>
  1239. <select
  1240. className={fieldClass}
  1241. value={editTransactionCostCenterId || ''}
  1242. onChange={(e) => setEditTransactionCostCenterId(e.target.value ? Number(e.target.value) : null)}
  1243. >
  1244. <option value="">{t('finance.noCostCenter', 'Personal (no cost center)')}</option>
  1245. {(costCenters || []).map((cc) => (
  1246. <option key={cc.id} value={cc.id}>{cc.name}</option>
  1247. ))}
  1248. </select>
  1249. </div>
  1250. <div>
  1251. <label className={labelClass}>{t('finance.amount', 'Amount')}</label>
  1252. <input
  1253. type="number"
  1254. step="0.01"
  1255. className={fieldClass}
  1256. value={editTransactionAmount}
  1257. onChange={(e) => setEditTransactionAmount(e.target.value)}
  1258. placeholder={t('finance.amountExample', 'e.g., 10.50')}
  1259. />
  1260. </div>
  1261. <div>
  1262. <label className={labelClass}>{t('common.description', 'Description')}</label>
  1263. <textarea
  1264. className={`${fieldClass} resize-none`}
  1265. rows={3}
  1266. value={editTransactionDescription}
  1267. onChange={(e) => setEditTransactionDescription(e.target.value)}
  1268. placeholder={t('finance.manualAdjustmentExample', 'e.g., Manual adjustment')}
  1269. />
  1270. </div>
  1271. <div className="flex justify-end gap-2 pt-4">
  1272. <Button
  1273. variant="secondary"
  1274. onClick={() => setShowEditTransactionModal(false)}
  1275. >
  1276. {t('common.cancel', 'Cancel')}
  1277. </Button>
  1278. <Button
  1279. onClick={handleSaveEditTransaction}
  1280. disabled={editTransactionMutation.isPending}
  1281. >
  1282. {t('common.save', 'Save')}
  1283. </Button>
  1284. </div>
  1285. </div>
  1286. </FinanceModal>
  1287. )}
  1288. {showManualPrintModal && (
  1289. <FinanceModal
  1290. title={t('finance.addManualPrint', 'Add manual print')}
  1291. onClose={() => setShowManualPrintModal(false)}
  1292. size="md"
  1293. >
  1294. <div className="space-y-4">
  1295. <div>
  1296. <label className={labelClass}>{t('common.user', 'User')}</label>
  1297. <select
  1298. className={fieldClass}
  1299. value={manualPrintUserId || ''}
  1300. onChange={(e) => setManualPrintUserId(e.target.value ? Number(e.target.value) : null)}
  1301. >
  1302. <option value="">{t('finance.selectUser', 'Select user...')}</option>
  1303. {sortedUsers.map((u) => (
  1304. <option key={u.id} value={u.id}>{u.username}</option>
  1305. ))}
  1306. </select>
  1307. </div>
  1308. <div>
  1309. <label className={labelClass}>{t('finance.costCenter', 'Cost center')}</label>
  1310. <select
  1311. className={fieldClass}
  1312. value={manualPrintCostCenterId || ''}
  1313. onChange={(e) => setManualPrintCostCenterId(e.target.value ? Number(e.target.value) : null)}
  1314. >
  1315. <option value="">{t('finance.selectCostCenter', 'Select cost center...')}</option>
  1316. {(costCenters || []).map((cc) => (
  1317. <option key={cc.id} value={cc.id}>{cc.name}</option>
  1318. ))}
  1319. </select>
  1320. </div>
  1321. <div>
  1322. <label className={labelClass}>{t('finance.amount', 'Amount')}</label>
  1323. <input
  1324. type="number"
  1325. step="0.01"
  1326. className={fieldClass}
  1327. value={manualPrintAmount}
  1328. onChange={(e) => setManualPrintAmount(e.target.value)}
  1329. placeholder={t('finance.amountExample', 'e.g., 4.00')}
  1330. />
  1331. </div>
  1332. <div>
  1333. <label className={labelClass}>{t('common.description', 'Description')}</label>
  1334. <input
  1335. type="text"
  1336. value={manualPrintDescription}
  1337. onChange={(e) => setManualPrintDescription(e.target.value)}
  1338. placeholder={t('finance.descriptionOptional', 'Description (optional)')}
  1339. className={fieldClass}
  1340. />
  1341. </div>
  1342. <div>
  1343. <label className={labelClass}>{t('common.date', 'Date')}</label>
  1344. <input
  1345. type="datetime-local"
  1346. value={manualPrintDate}
  1347. onChange={(e) => setManualPrintDate(e.target.value)}
  1348. className={fieldClass}
  1349. />
  1350. </div>
  1351. <div className="flex justify-end gap-2 pt-4">
  1352. <Button variant="secondary" onClick={() => setShowManualPrintModal(false)}>
  1353. {t('common.cancel', 'Cancel')}
  1354. </Button>
  1355. <Button onClick={handleSaveManualPrint} disabled={manualPrintMutation.isPending}>
  1356. {manualPrintMutation.isPending ? t('common.saving', 'Saving...') : t('finance.addManualPrint', 'Add manual print')}
  1357. </Button>
  1358. </div>
  1359. </div>
  1360. </FinanceModal>
  1361. )}
  1362. {pendingDeleteCenter && (
  1363. <ConfirmModal
  1364. title={t('common.delete', 'Delete')}
  1365. message={t('finance.confirmDeleteCostCenter', 'Delete cost center "{{name}}"?', {
  1366. name: pendingDeleteCenter.name,
  1367. })}
  1368. confirmText={t('common.delete', 'Delete')}
  1369. variant="danger"
  1370. isLoading={deleteCostCenterMutation.isPending}
  1371. onConfirm={confirmDeleteCenter}
  1372. onCancel={() => setPendingDeleteCenter(null)}
  1373. />
  1374. )}
  1375. {pendingDeleteTransactionId != null && (
  1376. <ConfirmModal
  1377. title={t('finance.deleteTransaction', 'Delete transaction')}
  1378. message={t(
  1379. 'finance.deleteTransactionConfirm',
  1380. 'Delete this transaction? Balances will be recalculated automatically.',
  1381. )}
  1382. confirmText={t('common.delete', 'Delete')}
  1383. variant="danger"
  1384. isLoading={deleteTransactionMutation.isPending}
  1385. onConfirm={confirmDeleteTransaction}
  1386. onCancel={() => setPendingDeleteTransactionId(null)}
  1387. />
  1388. )}
  1389. </div>
  1390. </div>
  1391. );
  1392. }