FinancePage.tsx 67 KB

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