| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519 |
- import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
- import { Building2, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Clock3, Pencil, Trash2, Wallet } from 'lucide-react';
- import { useEffect, useMemo, useState } from 'react';
- import { useTranslation } from 'react-i18next';
- import { api, type ManualPrintRequest, type TransactionEditRequest, type WalletTransaction } from '../api/client';
- import { Button } from '../components/Button';
- import { Card, CardContent, CardHeader } from '../components/Card';
- import { ConfirmModal } from '../components/ConfirmModal';
- import { useAuth } from '../contexts/AuthContext';
- import { useToast } from '../contexts/ToastContext';
- import { getCurrencySymbol } from '../utils/currency';
- import { parseUTCDate } from '../utils/date';
- type PartialPrintStatus = 'aborted' | 'failed' | 'cancelled';
- interface ParsedPrintChargeDescription {
- isPartial: boolean;
- partialType: PartialPrintStatus | null;
- cleanedDescription: string | null;
- }
- const PRINT_CHARGE_PARTIAL_REGEX = /\[(aborted|failed|cancelled):\s*([^\]]*)\]/i;
- function parsePrintChargeDescription(description: string | null): ParsedPrintChargeDescription {
- if (!description) {
- return { isPartial: false, partialType: null, cleanedDescription: null };
- }
- const match = description.match(PRINT_CHARGE_PARTIAL_REGEX);
- if (!match) {
- return { isPartial: false, partialType: null, cleanedDescription: description };
- }
- const cleanedDescription = description.replace(match[0], '').trim();
- return {
- isPartial: true,
- partialType: match[1].toLowerCase() as PartialPrintStatus,
- cleanedDescription: cleanedDescription || null,
- };
- }
- function formatTimestamp(value: string | null, locale: string): string {
- if (!value) return '-';
- const parsed = parseUTCDate(value);
- if (!parsed) return value;
- return parsed.toLocaleString(locale);
- }
- function parseBudgetValue(raw: string): number | null {
- const trimmed = raw.trim();
- if (!trimmed) return null;
- const parsed = Number.parseFloat(trimmed);
- return Number.isFinite(parsed) ? parsed : null;
- }
- function formatLocalDateTime(value: Date): string {
- const year = value.getFullYear();
- const month = String(value.getMonth() + 1).padStart(2, '0');
- const day = String(value.getDate()).padStart(2, '0');
- const hours = String(value.getHours()).padStart(2, '0');
- const minutes = String(value.getMinutes()).padStart(2, '0');
- return `${year}-${month}-${day}T${hours}:${minutes}`;
- }
- const fieldClass =
- 'w-full px-3 py-2 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded text-white placeholder-bambu-gray focus:outline-none focus:ring-1 focus:ring-bambu-green';
- const labelClass = 'block text-sm font-medium text-white mb-1';
- const tableHeadCellClass = 'px-4 py-3 text-left text-bambu-gray font-medium';
- const tableCellClass = 'px-4 py-3 align-top text-white';
- interface FinanceModalProps {
- title: string;
- onClose: () => void;
- children: React.ReactNode;
- size?: 'sm' | 'md' | 'lg';
- }
- function FinanceModal({ title, onClose, children, size = 'md' }: FinanceModalProps) {
- const { t } = useTranslation();
- const sizeClass = size === 'sm' ? 'max-w-xl' : size === 'lg' ? 'max-w-6xl' : 'max-w-4xl';
- return (
- <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4">
- <div className={`w-full ${sizeClass} overflow-hidden rounded-lg border border-bambu-dark-tertiary bg-bambu-dark-secondary`}>
- <div className="flex items-center justify-between border-b border-bambu-dark-tertiary px-4 py-3">
- <h3 className="text-lg font-semibold text-white">{title}</h3>
- <Button size="sm" variant="secondary" onClick={onClose}>{t('common.close', 'Close')}</Button>
- </div>
- <div className="max-h-[75vh] overflow-auto p-4">{children}</div>
- </div>
- </div>
- );
- }
- export function FinancePage() {
- const { t, i18n } = useTranslation();
- const { hasPermission, user } = useAuth();
- const { showToast } = useToast();
- const queryClient = useQueryClient();
- const canReadOwn = hasPermission('cost_centers:read_own');
- const canReadAllFinance = hasPermission('cost_centers:read_all');
- const canCreateCostCenters = hasPermission('cost_centers:create');
- const canUpdateCostCenters = hasPermission('cost_centers:modify');
- const canUpdateBudgets = hasPermission('cost_centers:modify');
- const canAssignCostCenterUsers = hasPermission('cost_centers:modify');
- const canAdjustWallet = hasPermission('cost_centers:modify');
- // Finance only ever labels a user or picks one to assign, so the slim
- // listing suffices -- and a billing operator should not need the admin-level
- // users:read (emails, roles, permission sets) to staff a cost center (#1894).
- const canReadUsers = hasPermission('users:read_slim') || hasPermission('users:read');
- const canAccessAllCostCenters =
- canReadAllFinance ||
- canCreateCostCenters ||
- canUpdateCostCenters ||
- canUpdateBudgets ||
- canAssignCostCenterUsers ||
- canAdjustWallet;
- const canAccessFinance = canReadOwn || canAccessAllCostCenters;
- const canViewMyCostCenters = canAccessFinance;
- const [newCenterName, setNewCenterName] = useState('');
- const [newCenterBudgetMode, setNewCenterBudgetMode] = useState<'total' | 'monthly'>('monthly');
- const [newCenterBudgetValue, setNewCenterBudgetValue] = useState('');
- const [selectedUserId, setSelectedUserId] = useState<number | null>(null);
- const [selectedAdjustmentType, setSelectedAdjustmentType] = useState<'deposit' | 'withdraw'>('deposit');
- const [adjustmentAmount, setAdjustmentAmount] = useState('');
- const [adjustmentDescription, setAdjustmentDescription] = useState('');
- const [adjustmentCostCenterId, setAdjustmentCostCenterId] = useState<number | null>(null);
- const [selectedManageCenterId, setSelectedManageCenterId] = useState<number | null>(null);
- const [memberUserId, setMemberUserId] = useState<number | null>(null);
- const [memberCanPrint, setMemberCanPrint] = useState(true);
- const [txOffset, setTxOffset] = useState(0);
- const txLimit = 50;
- const [txTypeFilter, setTxTypeFilter] = useState<string>('all');
- const [txCostCenterFilter, setTxCostCenterFilter] = useState<number | 'all'>('all');
- const [showCreateCenterModal, setShowCreateCenterModal] = useState(false);
- const [showAdjustWalletModal, setShowAdjustWalletModal] = useState(false);
- const [showMembersModal, setShowMembersModal] = useState(false);
- const [showEditCenterModal, setShowEditCenterModal] = useState(false);
- const [financeViewMode, setFinanceViewMode] = useState<'personal' | 'admin'>('personal');
- const [selectedEditCenterId, setSelectedEditCenterId] = useState<number | null>(null);
- const [editCenterName, setEditCenterName] = useState('');
- const [editCenterBudgetMode, setEditCenterBudgetMode] = useState<'total' | 'monthly'>('monthly');
- const [editCenterBudgetValue, setEditCenterBudgetValue] = useState('');
- const [showEditTransactionModal, setShowEditTransactionModal] = useState(false);
- const [selectedEditTransactionId, setSelectedEditTransactionId] = useState<number | null>(null);
- const [editTransactionUserId, setEditTransactionUserId] = useState<number | null>(null);
- const [editTransactionCostCenterId, setEditTransactionCostCenterId] = useState<number | null>(null);
- const [editTransactionAmount, setEditTransactionAmount] = useState('');
- const [editTransactionDescription, setEditTransactionDescription] = useState('');
- const [showManualPrintModal, setShowManualPrintModal] = useState(false);
- const [manualPrintUserId, setManualPrintUserId] = useState<number | null>(null);
- const [manualPrintCostCenterId, setManualPrintCostCenterId] = useState<number | null>(null);
- const [manualPrintAmount, setManualPrintAmount] = useState('');
- const [manualPrintDescription, setManualPrintDescription] = useState('');
- const [manualPrintDate, setManualPrintDate] = useState(formatLocalDateTime(new Date()));
- const [pendingDeleteCenter, setPendingDeleteCenter] = useState<{ id: number; name: string } | null>(null);
- const [pendingDeleteTransactionId, setPendingDeleteTransactionId] = useState<number | null>(null);
- const hasAdminFinanceControls =
- canReadAllFinance ||
- canCreateCostCenters ||
- canUpdateCostCenters ||
- canUpdateBudgets ||
- (canAdjustWallet && canReadUsers) ||
- (canAssignCostCenterUsers && canReadUsers);
- useEffect(() => {
- if (!hasAdminFinanceControls) {
- setFinanceViewMode('personal');
- }
- }, [hasAdminFinanceControls]);
- const { data: wallet, isLoading: walletLoading } = useQuery({
- queryKey: ['finance', 'me', 'balance'],
- queryFn: api.getMyBalance,
- enabled: canReadOwn,
- });
- // Currency comes from the install setting, like every other page. Read from
- // /settings/ui-flags rather than /settings, which needs SETTINGS_READ that a
- // cost_centers:read_own user does not have (#3023). The Layout already holds
- // this query key, so this is served from cache.
- const { data: uiFlags } = useQuery({
- queryKey: ['ui-flags'],
- queryFn: api.getUiFlags,
- });
- const { data: transactionsResponse, isLoading: personalTxLoading } = useQuery({
- queryKey: ['finance', 'me', 'transactions', txLimit, txOffset],
- queryFn: () => api.getMyTransactions(txLimit, txOffset),
- enabled: canReadOwn,
- });
- const { data: adminTransactionsResponse, isLoading: adminTxLoading } = useQuery({
- queryKey: ['finance', 'transactions', txLimit, txOffset],
- queryFn: () => api.getAllTransactions(txLimit, txOffset),
- enabled: financeViewMode === 'admin' && canReadAllFinance,
- });
- const { data: costCenters, isLoading: centersLoading } = useQuery({
- queryKey: ['finance', 'cost-centers', financeViewMode],
- queryFn: () => (financeViewMode === 'admin' && canAccessAllCostCenters ? api.listCostCenters(true) : api.getMyCostCenters()),
- enabled: canViewMyCostCenters,
- });
- const { data: users } = useQuery({
- queryKey: ['users', 'slim'],
- queryFn: api.getUsersSlim,
- enabled: canReadUsers && (canViewMyCostCenters || canAdjustWallet || canAssignCostCenterUsers),
- });
- const { data: selectedCenterDetail } = useQuery({
- queryKey: ['finance', 'cost-center', selectedManageCenterId],
- queryFn: () => api.getCostCenter(selectedManageCenterId!),
- enabled: canAssignCostCenterUsers && selectedManageCenterId != null,
- });
- useEffect(() => {
- if (!users || users.length === 0) return;
- if (selectedUserId != null && users.some((u) => u.id === selectedUserId)) return;
- setSelectedUserId(users[0].id);
- }, [users, selectedUserId]);
- useEffect(() => {
- if (!canAssignCostCenterUsers) return;
- const sharedCenters = (costCenters || []).filter((c) => !c.is_private);
- if (sharedCenters.length === 0) {
- setSelectedManageCenterId(null);
- return;
- }
- if (selectedManageCenterId != null && sharedCenters.some((c) => c.id === selectedManageCenterId)) return;
- setSelectedManageCenterId(sharedCenters[0].id);
- }, [canAssignCostCenterUsers, costCenters, selectedManageCenterId]);
- useEffect(() => {
- if (!canAssignCostCenterUsers || !users || users.length === 0) return;
- const existingMemberIds = new Set((selectedCenterDetail?.members || []).map((m) => m.user_id));
- const firstAvailable = users.find((u) => !existingMemberIds.has(u.id));
- setMemberUserId(firstAvailable ? firstAvailable.id : null);
- }, [canAssignCostCenterUsers, users, selectedCenterDetail]);
- useEffect(() => {
- if (!showEditCenterModal) return;
- const editableCenters = costCenters || [];
- if (editableCenters.length === 0) {
- setSelectedEditCenterId(null);
- return;
- }
- if (selectedEditCenterId != null && editableCenters.some((center) => center.id === selectedEditCenterId)) return;
- setSelectedEditCenterId(editableCenters[0].id);
- }, [showEditCenterModal, selectedEditCenterId, costCenters]);
- useEffect(() => {
- if (!showEditCenterModal || selectedEditCenterId == null) return;
- const center = (costCenters || []).find((entry) => entry.id === selectedEditCenterId);
- if (!center) return;
- setEditCenterName(center.name);
- if (center.budget_mode === 'total') {
- setEditCenterBudgetMode('total');
- setEditCenterBudgetValue(center.total_budget == null ? '' : String(center.total_budget));
- return;
- }
- setEditCenterBudgetMode('monthly');
- setEditCenterBudgetValue(center.monthly_budget == null ? '' : String(center.monthly_budget));
- }, [showEditCenterModal, selectedEditCenterId, costCenters]);
- const createCostCenterMutation = useMutation({
- mutationFn: () =>
- api.createCostCenter({
- name: newCenterName.trim(),
- total_budget: newCenterBudgetMode === 'total' ? parseBudgetValue(newCenterBudgetValue) : null,
- monthly_budget: newCenterBudgetMode === 'monthly' ? parseBudgetValue(newCenterBudgetValue) : null,
- is_active: true,
- }),
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ['finance'] });
- setNewCenterName('');
- setNewCenterBudgetValue('');
- showToast(t('finance.createdCostCenter', 'Cost center created'));
- },
- onError: (error: Error) => {
- showToast(error.message || t('finance.createCostCenterFailed', 'Failed to create cost center'), 'error');
- },
- });
- const updateBudgetMutation = useMutation({
- mutationFn: ({ costCenterId, value, mode }: { costCenterId: number; value: string; mode: 'total' | 'monthly' }) =>
- api.updateCostCenterBudgets(costCenterId, {
- total_budget: mode === 'total' ? parseBudgetValue(value) : null,
- monthly_budget: mode === 'monthly' ? parseBudgetValue(value) : null,
- }),
- });
- const updateCostCenterMutation = useMutation({
- mutationFn: ({ costCenterId, name }: { costCenterId: number; name: string }) =>
- api.updateCostCenter(costCenterId, {
- name,
- }),
- });
- const deleteCostCenterMutation = useMutation({
- mutationFn: (costCenterId: number) => api.deleteCostCenter(costCenterId),
- });
- const deleteTransactionMutation = useMutation({
- mutationFn: (transactionId: number) => api.deleteTransaction(transactionId),
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ['finance'] });
- showToast(t('finance.transactionDeleted', 'Transaction deleted'));
- },
- onError: (error: Error) => {
- showToast(error.message || t('finance.deleteTransactionFailed', 'Failed to delete transaction'), 'error');
- },
- });
- const editTransactionMutation = useMutation({
- mutationFn: (payload: { transactionId: number; data: TransactionEditRequest }) =>
- api.editTransaction(payload.transactionId, payload.data),
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ['finance'] });
- showToast(t('finance.transactionEdited', 'Transaction updated and ledger recalculated'));
- setShowEditTransactionModal(false);
- },
- onError: (error: Error) => {
- showToast(error.message || t('finance.editTransactionFailed', 'Failed to edit transaction'), 'error');
- },
- });
- const manualPrintMutation = useMutation({
- mutationFn: (data: ManualPrintRequest) => api.createManualPrint(data),
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ['finance'] });
- showToast(t('finance.manualPrintCreated', 'Manual print charge added and ledger recalculated'));
- setShowManualPrintModal(false);
- setManualPrintUserId(null);
- setManualPrintCostCenterId(null);
- setManualPrintAmount('');
- setManualPrintDescription('');
- setManualPrintDate(formatLocalDateTime(new Date()));
- },
- onError: (error: Error) => {
- showToast(error.message || t('finance.manualPrintFailed', 'Failed to create manual print'), 'error');
- },
- });
- const depositMutation = useMutation({
- mutationFn: (payload: { userId: number; amount: number; description?: string; costCenterId?: number | null }) =>
- api.depositUserBalance(payload.userId, {
- amount: payload.amount,
- description: payload.description,
- cost_center_id: payload.costCenterId ?? null,
- }),
- });
- const withdrawMutation = useMutation({
- mutationFn: (payload: { userId: number; amount: number; description?: string; costCenterId?: number | null }) =>
- api.withdrawUserBalance(payload.userId, {
- amount: payload.amount,
- description: payload.description,
- cost_center_id: payload.costCenterId ?? null,
- }),
- });
- const upsertMemberMutation = useMutation({
- mutationFn: (payload: { costCenterId: number; userId: number; canPrint: boolean }) =>
- api.upsertCostCenterMember(payload.costCenterId, {
- user_id: payload.userId,
- can_print: payload.canPrint,
- }),
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ['finance'] });
- showToast(t('finance.memberSaved', 'Member saved'));
- },
- onError: (error: Error) => {
- showToast(error.message || t('finance.memberSaveFailed', 'Failed to save member'), 'error');
- },
- });
- const removeMemberMutation = useMutation({
- mutationFn: (payload: { costCenterId: number; userId: number }) =>
- api.removeCostCenterMember(payload.costCenterId, payload.userId),
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ['finance'] });
- showToast(t('finance.memberRemoved', 'Member removed'));
- },
- onError: (error: Error) => {
- showToast(error.message || t('finance.memberRemoveFailed', 'Failed to remove member'), 'error');
- },
- });
- const isAdjustingWallet = depositMutation.isPending || withdrawMutation.isPending;
- const handleCreateCostCenter = () => {
- if (!newCenterName.trim()) {
- showToast(t('finance.costCenterNameRequired', 'Cost center name is required'), 'error');
- return;
- }
- createCostCenterMutation.mutate();
- };
- // A private center is named after its owner and can't be renamed; the backend
- // refuses the rename, which used to stop the budget from being saved (#3358).
- const selectedEditCenterIsPrivate = (costCenters || []).some(
- (c) => c.id === selectedEditCenterId && c.is_private,
- );
- const handleSaveEditedCenter = async () => {
- if (selectedEditCenterId == null) {
- showToast(t('finance.selectCostCenter', 'Please select a cost center'), 'error');
- return;
- }
- const name = editCenterName.trim();
- if (!name) {
- showToast(t('finance.costCenterNameRequired', 'Cost center name is required'), 'error');
- return;
- }
- try {
- if (canUpdateCostCenters && !selectedEditCenterIsPrivate) {
- await updateCostCenterMutation.mutateAsync({ costCenterId: selectedEditCenterId, name });
- }
- if (canUpdateBudgets) {
- await updateBudgetMutation.mutateAsync({
- costCenterId: selectedEditCenterId,
- mode: editCenterBudgetMode,
- value: editCenterBudgetValue,
- });
- }
- queryClient.invalidateQueries({ queryKey: ['finance'] });
- showToast(t('finance.costCenterUpdated', 'Cost center updated'));
- setShowEditCenterModal(false);
- } catch (error) {
- showToast((error as Error).message || t('finance.costCenterUpdateFailed', 'Failed to update cost center'), 'error');
- }
- };
- const handleOpenEditCenter = (costCenterId: number) => {
- const center = (costCenters || []).find((entry) => entry.id === costCenterId);
- if (!center) return;
- setSelectedEditCenterId(center.id);
- setEditCenterName(center.name);
- if (center.budget_mode === 'total') {
- setEditCenterBudgetMode('total');
- setEditCenterBudgetValue(center.total_budget == null ? '' : String(center.total_budget));
- } else {
- setEditCenterBudgetMode('monthly');
- setEditCenterBudgetValue(center.monthly_budget == null ? '' : String(center.monthly_budget));
- }
- setShowEditCenterModal(true);
- };
- const confirmDeleteCenter = async () => {
- if (!pendingDeleteCenter) return;
- try {
- await deleteCostCenterMutation.mutateAsync(pendingDeleteCenter.id);
- queryClient.invalidateQueries({ queryKey: ['finance'] });
- if (selectedManageCenterId === pendingDeleteCenter.id) {
- setSelectedManageCenterId(null);
- }
- showToast(t('finance.costCenterDeleted', 'Cost center deleted'));
- } catch (error) {
- showToast((error as Error).message || t('finance.costCenterDeleteFailed', 'Failed to delete cost center'), 'error');
- } finally {
- setPendingDeleteCenter(null);
- }
- };
- const confirmDeleteTransaction = () => {
- if (pendingDeleteTransactionId == null) return;
- deleteTransactionMutation.mutate(pendingDeleteTransactionId, {
- onSettled: () => setPendingDeleteTransactionId(null),
- });
- };
- const handleEditTransaction = (tx: WalletTransaction) => {
- setSelectedEditTransactionId(tx.id);
- setEditTransactionUserId(tx.user_id);
- setEditTransactionCostCenterId(tx.cost_center_id);
- setEditTransactionAmount(String(tx.amount));
- setEditTransactionDescription(tx.description || '');
- setShowEditTransactionModal(true);
- };
- const handleSaveEditTransaction = async () => {
- if (selectedEditTransactionId == null) return;
- const amount = editTransactionAmount ? Number.parseFloat(editTransactionAmount) : null;
- if (amount !== null && !Number.isFinite(amount)) {
- showToast(t('finance.amountInvalid', 'Amount must be a valid number'), 'error');
- return;
- }
- editTransactionMutation.mutate({
- transactionId: selectedEditTransactionId,
- data: {
- user_id: editTransactionUserId || undefined,
- // null moves it to no cost center; left out, the backend keeps the old one.
- cost_center_id: editTransactionCostCenterId || null,
- amount: amount ?? undefined,
- description: editTransactionDescription || undefined,
- },
- });
- };
- const handleSaveManualPrint = async () => {
- if (manualPrintUserId == null) {
- showToast(t('finance.userRequired', 'Please select a user'), 'error');
- return;
- }
- if (manualPrintCostCenterId == null) {
- showToast(t('finance.costCenterRequired', 'Please select a cost center'), 'error');
- return;
- }
- let amount = manualPrintAmount ? Number.parseFloat(manualPrintAmount) : null;
- if (amount === null || !Number.isFinite(amount)) {
- showToast(t('finance.amountInvalid', 'Amount must be a valid number'), 'error');
- return;
- }
- amount = Math.abs(amount);
- manualPrintMutation.mutate({
- user_id: manualPrintUserId,
- cost_center_id: manualPrintCostCenterId,
- amount: amount,
- description: manualPrintDescription || undefined,
- created_at: manualPrintDate ? new Date(manualPrintDate).toISOString() : undefined,
- });
- };
- const handleWalletAdjustment = async () => {
- if (selectedUserId == null) {
- showToast(t('finance.userRequired', 'Please select a user'), 'error');
- return;
- }
- const amount = Number.parseFloat(adjustmentAmount);
- if (!Number.isFinite(amount) || amount <= 0) {
- showToast(t('finance.amountMustBePositive', 'Amount must be greater than zero'), 'error');
- return;
- }
- const payload = {
- userId: selectedUserId,
- amount,
- description: adjustmentDescription.trim() || undefined,
- costCenterId: adjustmentCostCenterId,
- };
- try {
- if (selectedAdjustmentType === 'deposit') {
- await depositMutation.mutateAsync(payload);
- } else {
- await withdrawMutation.mutateAsync(payload);
- }
- queryClient.invalidateQueries({ queryKey: ['finance'] });
- setAdjustmentAmount('');
- setAdjustmentDescription('');
- showToast(
- selectedAdjustmentType === 'deposit'
- ? t('finance.depositSuccess', 'Deposit successful')
- : t('finance.withdrawSuccess', 'Withdrawal successful')
- );
- } catch (error) {
- showToast((error as Error).message || t('finance.adjustmentFailed', 'Wallet adjustment failed'), 'error');
- }
- };
- const handleAddMember = () => {
- if (selectedManageCenterId == null) {
- showToast(t('finance.selectCostCenter', 'Please select a cost center'), 'error');
- return;
- }
- if (memberUserId == null) {
- showToast(t('finance.noEligibleUsers', 'No eligible users available'), 'error');
- return;
- }
- upsertMemberMutation.mutate({
- costCenterId: selectedManageCenterId,
- userId: memberUserId,
- canPrint: memberCanPrint,
- });
- };
- const handleRemoveMember = (userId: number) => {
- if (selectedManageCenterId == null) return;
- removeMemberMutation.mutate({ costCenterId: selectedManageCenterId, userId });
- };
- const currency = uiFlags?.currency || 'USD';
- const currencySymbol = getCurrencySymbol(currency);
- const sortedUsers = useMemo(() => {
- return [...(users || [])].sort((a, b) => a.username.localeCompare(b.username));
- }, [users]);
- const usersById = useMemo(() => {
- const map = new Map<number, string>();
- for (const entry of sortedUsers) {
- map.set(entry.id, entry.username);
- }
- return map;
- }, [sortedUsers]);
- const availableUsersForCenter = useMemo(() => {
- const existingIds = new Set((selectedCenterDetail?.members || []).map((m) => m.user_id));
- return sortedUsers.filter((u) => !existingIds.has(u.id));
- }, [sortedUsers, selectedCenterDetail]);
- const activeTransactionsResponse =
- financeViewMode === 'admin' && canReadAllFinance
- ? adminTransactionsResponse
- : transactionsResponse;
- const txLoading = (financeViewMode === 'admin' && canReadAllFinance)
- ? adminTxLoading
- : personalTxLoading;
- const transactions = useMemo(
- () => activeTransactionsResponse?.items ?? [],
- [activeTransactionsResponse?.items]
- );
- const txTotal = activeTransactionsResponse?.total ?? 0;
- const txTotalPages = Math.max(1, Math.ceil(txTotal / txLimit));
- const filteredTransactions = useMemo(() => {
- const items = transactions;
- return items.filter((tx) => {
- if (txTypeFilter !== 'all' && tx.transaction_type !== txTypeFilter) return false;
- if (txCostCenterFilter !== 'all' && tx.cost_center_id !== txCostCenterFilter) return false;
- return true;
- });
- }, [transactions, txTypeFilter, txCostCenterFilter]);
- const txPage = Math.floor(txOffset / txLimit) + 1;
- const showCostCenterAccountColumn = financeViewMode === 'admin';
- const getTransactionTypeLabel = (transactionType: string): string => {
- if (transactionType === 'deposit') return t('finance.deposit');
- if (transactionType === 'withdraw') return t('finance.withdraw');
- if (transactionType === 'print_charge') return t('finance.printCharge');
- return transactionType;
- };
- const getPrivateOwnerLabel = (ownerUserId: number | null): string => {
- // Private centres are always created with an owner; none means they were deleted.
- if (ownerUserId == null) return t('finance.deletedUser', 'Deleted user');
- const ownerName = usersById.get(ownerUserId);
- if (ownerName) return ownerName;
- if (user?.id === ownerUserId && user.username) return user.username;
- return t('finance.userWithId', 'User #{{id}}', { id: ownerUserId });
- };
- const formatBudgetProgress = (center: { budget_available: number | null; budget_limit: number | null }) => {
- if (center.budget_limit == null || center.budget_available == null) return t('finance.unlimited', 'Unlimited');
- return `${currencySymbol}${center.budget_available.toFixed(2)}/${currencySymbol}${center.budget_limit.toFixed(2)}`;
- };
- if (!canAccessFinance) {
- return (
- <div className="p-4 md:p-8 space-y-6">
- <div className="flex items-center gap-2">
- <Wallet className="h-6 w-6 text-bambu-green" />
- <h1 className="text-2xl font-bold text-white">{t('finance.title', 'Finance')}</h1>
- </div>
- <div>
- <p className="text-bambu-gray mt-2 max-w-2xl">{t('finance.subtitle', 'Wallet, personal transactions, and cost centers')}</p>
- </div>
- <Card>
- <CardContent className="py-8 text-center text-bambu-gray">
- {t('finance.noAccess', 'You do not have permission to view finance data.')}
- </CardContent>
- </Card>
- </div>
- );
- }
- return (
- <div className="p-4 md:p-8 space-y-8">
- <div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
- <div>
- <div className="flex items-center gap-2">
- <Wallet className="h-6 w-6 text-bambu-green" />
- <h1 className="text-2xl font-bold text-white">{t('finance.title', 'Finance')}</h1>
- </div>
- <p className="text-bambu-gray mt-2 max-w-2xl">{t('finance.subtitle', 'Wallet, personal transactions, and cost centers')}</p>
- <p className="text-xs text-bambu-gray mt-1 max-w-2xl">
- {t('finance.budgetPolicyHint', 'Account balances track costs. Printing is limited only by the selected cost center budget; without a budget, printing is unlimited.')}
- </p>
- </div>
- <div className="flex flex-col gap-2 lg:items-end">
- {hasAdminFinanceControls && (
- <div className="inline-flex overflow-hidden rounded border border-bambu-dark-tertiary">
- <button
- type="button"
- onClick={() => setFinanceViewMode('personal')}
- className={`px-3 py-1.5 text-sm ${financeViewMode === 'personal' ? 'bg-bambu-green text-black font-medium' : 'bg-bambu-dark text-bambu-gray'}`}
- >
- {t('finance.personalView', 'Personal view')}
- </button>
- <button
- type="button"
- onClick={() => setFinanceViewMode('admin')}
- className={`px-3 py-1.5 text-sm ${financeViewMode === 'admin' ? 'bg-bambu-green text-black font-medium' : 'bg-bambu-dark text-bambu-gray'}`}
- >
- {t('finance.adminView', 'Admin view')}
- </button>
- </div>
- )}
- {financeViewMode === 'admin' && hasAdminFinanceControls && (
- <div className="flex flex-wrap gap-2 lg:justify-end">
- {canCreateCostCenters && (
- <Button size="sm" variant="secondary" onClick={() => setShowCreateCenterModal(true)}>
- {t('finance.createCostCenter', 'Create cost center')}
- </Button>
- )}
- {canAdjustWallet && canReadUsers && (
- <Button size="sm" variant="secondary" onClick={() => setShowAdjustWalletModal(true)}>
- {t('finance.adjustWallet', 'Adjust wallet')}
- </Button>
- )}
- {canAdjustWallet && canReadUsers && (
- <Button size="sm" variant="secondary" onClick={() => setShowManualPrintModal(true)}>
- {t('finance.addManualPrint', 'Add manual print')}
- </Button>
- )}
- {canAssignCostCenterUsers && canReadUsers && (
- <Button size="sm" variant="secondary" onClick={() => setShowMembersModal(true)}>
- {t('finance.manageMembers', 'Manage cost center members')}
- </Button>
- )}
- </div>
- )}
- </div>
- </div>
- <div className="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
- <Card>
- <CardHeader className="flex flex-row items-center justify-between pb-2">
- <span className="text-sm text-bambu-gray">{t('finance.currentBalance', 'Personal account balance')}</span>
- <Wallet className="w-4 h-4 text-bambu-green/90" />
- </CardHeader>
- <CardContent className="pt-1">
- <p className="text-3xl font-semibold text-white leading-tight">
- {walletLoading ? t('common.loading', 'Loading...') : `${currencySymbol}${(wallet?.balance ?? 0).toFixed(2)}`}
- </p>
- </CardContent>
- </Card>
- <Card>
- <CardHeader className="flex flex-row items-center justify-between pb-2">
- <span className="text-sm text-bambu-gray">
- {financeViewMode === 'admin'
- ? t('finance.transactions', 'Transactions')
- : t('finance.personalTransactions', 'Personal transactions')}
- </span>
- <Clock3 className="w-4 h-4 text-blue-400" />
- </CardHeader>
- <CardContent className="pt-1">
- <p className="text-3xl font-semibold text-white leading-tight">{txLoading ? '-' : txTotal}</p>
- </CardContent>
- </Card>
- <Card>
- <CardHeader className="flex flex-row items-center justify-between pb-2">
- <span className="text-sm text-bambu-gray">{t('finance.costCenters', 'Cost centers')}</span>
- <Building2 className="w-4 h-4 text-orange-400" />
- </CardHeader>
- <CardContent className="pt-1">
- <p className="text-3xl font-semibold text-white leading-tight">{centersLoading ? '-' : costCenters?.length ?? 0}</p>
- </CardContent>
- </Card>
- </div>
- {showCreateCenterModal && canCreateCostCenters && (
- <FinanceModal title={t('finance.createCostCenter', 'Create cost center')} size="md" onClose={() => setShowCreateCenterModal(false)}>
- <div className="space-y-4">
- <div className="grid gap-4 md:grid-cols-3">
- <div>
- <label className={labelClass}>{t('finance.costCenterName', 'Name')}</label>
- <input
- type="text"
- value={newCenterName}
- onChange={(e) => setNewCenterName(e.target.value)}
- placeholder={t('finance.costCenterName', 'Name')}
- className={fieldClass}
- />
- </div>
- <div>
- <label className={labelClass}>{t('finance.budgetType', 'Budget type')}</label>
- <select
- value={newCenterBudgetMode}
- onChange={(e) => setNewCenterBudgetMode(e.target.value as 'total' | 'monthly')}
- className={fieldClass}
- >
- <option value="monthly">{t('finance.monthlyBudget', 'Monthly budget')}</option>
- <option value="total">{t('finance.totalBudget', 'Total budget')}</option>
- </select>
- </div>
- <div>
- <label className={labelClass}>
- {newCenterBudgetMode === 'monthly' ? t('finance.monthlyBudget', 'Monthly budget') : t('finance.totalBudget', 'Total budget')}
- </label>
- <input
- type="number"
- step="0.01"
- value={newCenterBudgetValue}
- onChange={(e) => setNewCenterBudgetValue(e.target.value)}
- placeholder="0.00"
- className={fieldClass}
- />
- </div>
- </div>
- <Button className="min-w-[180px]" onClick={handleCreateCostCenter} disabled={createCostCenterMutation.isPending}>
- {createCostCenterMutation.isPending ? t('common.saving', 'Saving...') : t('finance.create', 'Create')}
- </Button>
- </div>
- </FinanceModal>
- )}
- {showAdjustWalletModal && canAdjustWallet && canReadUsers && (
- <FinanceModal title={t('finance.adjustWallet', 'Adjust wallet')} size="md" onClose={() => setShowAdjustWalletModal(false)}>
- <div className="space-y-4">
- <div className="grid gap-4 md:grid-cols-2">
- <div>
- <label className={labelClass}>{t('finance.selectUser', 'Select user')}</label>
- <select
- value={selectedUserId ?? ''}
- onChange={(e) => setSelectedUserId(e.target.value ? Number(e.target.value) : null)}
- className={fieldClass}
- >
- {(sortedUsers || []).map((u) => (
- <option key={u.id} value={u.id}>{u.username}</option>
- ))}
- </select>
- </div>
- <div>
- <label className={labelClass}>{t('finance.transactionType', 'Type')}</label>
- <select
- value={selectedAdjustmentType}
- onChange={(e) => setSelectedAdjustmentType(e.target.value as 'deposit' | 'withdraw')}
- className={fieldClass}
- >
- <option value="deposit">{t('finance.deposit', 'Deposit')}</option>
- <option value="withdraw">{t('finance.withdraw', 'Withdraw')}</option>
- </select>
- </div>
- <div>
- <label className={labelClass}>{t('finance.amount', 'Amount')}</label>
- <input
- type="number"
- step="0.01"
- min="0"
- value={adjustmentAmount}
- onChange={(e) => setAdjustmentAmount(e.target.value)}
- placeholder="0.00"
- className={fieldClass}
- />
- </div>
- <div>
- <label className={labelClass}>{t('finance.costCenters', 'Cost centers')}</label>
- <select
- value={adjustmentCostCenterId ?? ''}
- onChange={(e) => setAdjustmentCostCenterId(e.target.value ? Number(e.target.value) : null)}
- className={fieldClass}
- >
- <option value="">{t('finance.noCostCenter', 'No cost center')}</option>
- {(costCenters || []).map((center) => (
- <option key={center.id} value={center.id}>{center.name}</option>
- ))}
- </select>
- </div>
- </div>
- <div>
- <label className={labelClass}>{t('common.description', 'Description')}</label>
- <input
- type="text"
- value={adjustmentDescription}
- onChange={(e) => setAdjustmentDescription(e.target.value)}
- placeholder={t('finance.descriptionOptional', 'Description (optional)')}
- className={fieldClass}
- />
- </div>
- <Button className="min-w-[180px]" onClick={handleWalletAdjustment} disabled={isAdjustingWallet}>
- {isAdjustingWallet ? t('common.saving', 'Saving...') : t('finance.applyAdjustment', 'Apply adjustment')}
- </Button>
- </div>
- </FinanceModal>
- )}
- {showMembersModal && canAssignCostCenterUsers && canReadUsers && (
- <FinanceModal title={t('finance.manageMembers', 'Manage cost center members')} size="lg" onClose={() => setShowMembersModal(false)}>
- <div className="space-y-5">
- <div className="grid gap-5 md:grid-cols-2">
- <div>
- <label className={labelClass}>{t('finance.costCenters', 'Cost centers')}</label>
- <select
- value={selectedManageCenterId ?? ''}
- onChange={(e) => setSelectedManageCenterId(e.target.value ? Number(e.target.value) : null)}
- className={fieldClass}
- >
- {(costCenters || [])
- .filter((center) => !center.is_private)
- .map((center) => (
- <option key={center.id} value={center.id}>{center.name}</option>
- ))}
- </select>
- </div>
- <div>
- <label className={labelClass}>{t('finance.selectUser', 'Select user')}</label>
- <select
- value={memberUserId ?? ''}
- onChange={(e) => setMemberUserId(e.target.value ? Number(e.target.value) : null)}
- className={fieldClass}
- >
- <option value="">{t('finance.selectUser', 'Select user')}</option>
- {availableUsersForCenter.map((u) => (
- <option key={u.id} value={u.id}>{u.username}</option>
- ))}
- </select>
- </div>
- <div className="flex items-center gap-2 rounded border border-bambu-dark-tertiary bg-bambu-dark-secondary px-3 py-2">
- <input
- id="memberCanPrint"
- type="checkbox"
- checked={memberCanPrint}
- onChange={(e) => setMemberCanPrint(e.target.checked)}
- className="rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
- />
- <label htmlFor="memberCanPrint" className="text-sm text-bambu-gray">
- {t('finance.memberCanPrint', 'Member can print')}
- </label>
- </div>
- <div className="flex items-end">
- <Button className="min-w-[180px]" onClick={handleAddMember} disabled={upsertMemberMutation.isPending || memberUserId == null}>
- {upsertMemberMutation.isPending ? t('common.saving', 'Saving...') : t('finance.addMember', 'Add member')}
- </Button>
- </div>
- </div>
- <div className="overflow-auto rounded-lg border border-bambu-dark-tertiary">
- <table className="w-full text-sm">
- <thead>
- <tr className="border-b border-bambu-dark-tertiary bg-bambu-dark text-bambu-gray">
- <th className={tableHeadCellClass}>{t('common.name', 'Name')}</th>
- <th className={tableHeadCellClass}>{t('finance.canPrint', 'Can print')}</th>
- <th className={tableHeadCellClass}>{t('common.actions', 'Actions')}</th>
- </tr>
- </thead>
- <tbody>
- {(selectedCenterDetail?.members || []).map((member) => {
- const user = sortedUsers.find((u) => u.id === member.user_id);
- return (
- <tr key={member.id} className="border-b border-bambu-dark-tertiary/60 text-white">
- <td className={tableCellClass}>{user?.username || `User ${member.user_id}`}</td>
- <td className={tableCellClass}>{member.can_print ? t('common.yes', 'Yes') : t('common.no', 'No')}</td>
- <td className={tableCellClass}>
- <Button
- size="sm"
- variant="danger"
- onClick={() => handleRemoveMember(member.user_id)}
- disabled={removeMemberMutation.isPending}
- >
- {t('common.remove', 'Remove')}
- </Button>
- </td>
- </tr>
- );
- })}
- {(selectedCenterDetail?.members || []).length === 0 && (
- <tr>
- <td colSpan={3} className="py-3 text-bambu-gray">
- {t('finance.noMembers', 'No members assigned.')}
- </td>
- </tr>
- )}
- </tbody>
- </table>
- </div>
- </div>
- </FinanceModal>
- )}
- {showEditCenterModal && (canUpdateCostCenters || canUpdateBudgets) && (
- <FinanceModal title={t('finance.editCostCenter', 'Edit cost center')} size="md" onClose={() => setShowEditCenterModal(false)}>
- <div className="space-y-4">
- <div>
- <label className={labelClass}>{t('finance.costCenterName', 'Name')}</label>
- <input
- type="text"
- value={editCenterName}
- onChange={(e) => setEditCenterName(e.target.value)}
- placeholder={t('finance.costCenterName', 'Name')}
- className={fieldClass}
- disabled={!canUpdateCostCenters || selectedEditCenterIsPrivate}
- />
- </div>
- <div className="grid gap-4 md:grid-cols-2">
- <div>
- <label className={labelClass}>{t('finance.budgetType', 'Budget type')}</label>
- <select
- value={editCenterBudgetMode}
- onChange={(e) => setEditCenterBudgetMode(e.target.value as 'total' | 'monthly')}
- className={fieldClass}
- disabled={!canUpdateBudgets}
- >
- <option value="monthly">{t('finance.monthlyBudget', 'Monthly budget')}</option>
- <option value="total">{t('finance.totalBudget', 'Total budget')}</option>
- </select>
- </div>
- <div>
- <label className={labelClass}>
- {editCenterBudgetMode === 'monthly' ? t('finance.monthlyBudget', 'Monthly budget') : t('finance.totalBudget', 'Total budget')}
- </label>
- <input
- type="number"
- step="0.01"
- value={editCenterBudgetValue}
- onChange={(e) => setEditCenterBudgetValue(e.target.value)}
- placeholder="0.00"
- className={fieldClass}
- disabled={!canUpdateBudgets}
- />
- </div>
- </div>
- <Button
- className="min-w-[180px]"
- onClick={handleSaveEditedCenter}
- disabled={updateCostCenterMutation.isPending || updateBudgetMutation.isPending || selectedEditCenterId == null}
- >
- {(updateCostCenterMutation.isPending || updateBudgetMutation.isPending)
- ? t('common.saving', 'Saving...')
- : t('common.save', 'Save')}
- </Button>
- </div>
- </FinanceModal>
- )}
- <div className={`grid gap-6 ${canViewMyCostCenters && canReadOwn ? 'xl:grid-cols-2' : ''}`}>
- {canViewMyCostCenters && (
- <Card>
- <CardHeader>
- <h2 className="text-lg font-semibold text-white">{t('finance.myCostCenters', 'My cost centers')}</h2>
- <p className="text-sm text-bambu-gray mt-1">{t('finance.costCentersHint', 'Review budget limits and keep costs under control')}</p>
- </CardHeader>
- <CardContent className="space-y-3">
- {centersLoading && <p className="text-sm text-bambu-gray">{t('common.loading', 'Loading...')}</p>}
- {!centersLoading && (!costCenters || costCenters.length === 0) && (
- <p className="text-sm text-bambu-gray">{t('finance.noCostCenters', 'No cost centers found.')}</p>
- )}
- {!centersLoading && costCenters && costCenters.length > 0 && (
- <div className="overflow-auto rounded-lg border border-bambu-dark-tertiary">
- <table className="w-full text-sm">
- <thead>
- <tr className="border-b border-bambu-dark-tertiary bg-bambu-dark text-bambu-gray">
- <th className={tableHeadCellClass}>{t('common.name', 'Name')}</th>
- {showCostCenterAccountColumn && <th className={tableHeadCellClass}>{t('finance.owner', 'Owner')}</th>}
- <th className={tableHeadCellClass}>{t('finance.balance', 'Account balance')}</th>
- <th className={tableHeadCellClass}>{t('finance.budget', 'Budget')}</th>
- {showCostCenterAccountColumn && <th className={tableHeadCellClass}>{t('common.actions', 'Actions')}</th>}
- </tr>
- </thead>
- <tbody>
- {costCenters.map((center) => {
- const canEditRow = canUpdateCostCenters || canUpdateBudgets;
- const canDeleteRow = !center.is_private && canUpdateCostCenters;
- return (
- <tr key={center.id} className="border-b border-bambu-dark-tertiary/60 text-white">
- <td className={tableCellClass}>{center.name}</td>
- {showCostCenterAccountColumn && (
- <td className={tableCellClass}>{center.is_private ? getPrivateOwnerLabel(center.owner_user_id) : t('finance.shared', 'Shared')}</td>
- )}
- <td className={tableCellClass}>{currencySymbol}{center.total_balance.toFixed(2)}</td>
- <td className={tableCellClass}>
- <div className="flex flex-col gap-0.5">
- <span>{formatBudgetProgress(center)}</span>
- <span className="text-xs text-bambu-gray">
- {center.budget_mode === 'monthly'
- ? t('finance.monthlyBudget', 'Monthly budget')
- : center.budget_mode === 'total'
- ? t('finance.totalBudget', 'Total budget')
- : t('finance.noBudget', 'No budget')}
- </span>
- </div>
- </td>
- {showCostCenterAccountColumn && (
- <td className={tableCellClass}>
- <div className="flex items-center gap-2">
- <Button
- size="sm"
- variant="ghost"
- onClick={() => handleOpenEditCenter(center.id)}
- disabled={!canEditRow}
- title={canEditRow ? t('common.edit', 'Edit') : t('finance.cannotEditPrivateCostCenter', 'Private cost centers cannot be edited here')}
- className="p-1.5 sm:p-2"
- >
- <Pencil className="w-4 h-4" />
- </Button>
- {canDeleteRow && (
- <Button
- size="sm"
- variant="ghost"
- onClick={() => setPendingDeleteCenter({ id: center.id, name: center.name })}
- disabled={deleteCostCenterMutation.isPending}
- title={t('common.delete', 'Delete')}
- className="text-red-400 hover:text-red-300 hover:bg-red-500/10 p-1.5 sm:p-2"
- >
- <Trash2 className="w-4 h-4" />
- </Button>
- )}
- </div>
- </td>
- )}
- </tr>
- );
- })}
- </tbody>
- </table>
- </div>
- )}
- </CardContent>
- </Card>
- )}
- {canReadOwn && (
- <Card>
- <CardHeader>
- <h2 className="text-lg font-semibold text-white">{t('finance.recentTransactions', 'Recent transactions')}</h2>
- <p className="text-sm text-bambu-gray mt-1">{t('finance.transactionsHint', 'Filter by type and cost center, then navigate pages')}</p>
- </CardHeader>
- <CardContent className="space-y-4">
- <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">
- <div>
- <label className={labelClass}>{t('finance.transactionType', 'Type')}</label>
- <select
- value={txTypeFilter}
- onChange={(e) => setTxTypeFilter(e.target.value)}
- className={fieldClass}
- >
- <option value="all">{t('finance.allTypes')}</option>
- <option value="deposit">{t('finance.deposit')}</option>
- <option value="withdraw">{t('finance.withdraw')}</option>
- <option value="print_charge">{t('finance.printCharge')}</option>
- </select>
- </div>
- <div>
- <label className={labelClass}>{t('finance.costCenters', 'Cost centers')}</label>
- <select
- value={txCostCenterFilter}
- onChange={(e) => setTxCostCenterFilter(e.target.value === 'all' ? 'all' : Number(e.target.value))}
- className={fieldClass}
- >
- <option value="all">{t('finance.allCostCenters')}</option>
- {(costCenters || []).map((center) => (
- <option key={center.id} value={center.id}>{center.name}</option>
- ))}
- </select>
- </div>
- </div>
- {txLoading && <p className="text-sm text-bambu-gray">{t('common.loading', 'Loading...')}</p>}
- {!txLoading && (!transactions || transactions.length === 0) && (
- <p className="text-sm text-bambu-gray">{t('finance.noTransactions', 'No transactions available.')}</p>
- )}
- {!txLoading && transactions.length > 0 && filteredTransactions.length === 0 && (
- <p className="text-sm text-bambu-gray">{t('finance.noTransactionsForFilter', 'No transactions match the selected filters.')}</p>
- )}
- {!txLoading && filteredTransactions.length > 0 && (
- <div className="overflow-auto rounded-lg border border-bambu-dark-tertiary">
- <table className="w-full text-sm">
- <thead>
- <tr className="border-b border-bambu-dark-tertiary bg-bambu-dark text-bambu-gray">
- <th className={tableHeadCellClass}>{t('common.date', 'Date')}</th>
- {showCostCenterAccountColumn && <th className={tableHeadCellClass}>{t('common.user', 'User')}</th>}
- <th className={tableHeadCellClass}>{t('finance.costCenter', 'Cost center')}</th>
- <th className={tableHeadCellClass}>{t('finance.transactionType', 'Type')}</th>
- <th className={tableHeadCellClass}>{t('common.description', 'Description')}</th>
- <th className={tableHeadCellClass}>{t('finance.amount', 'Amount')}</th>
- <th className={tableHeadCellClass}>{t('finance.balanceAfter', 'Balance after')}</th>
- {showCostCenterAccountColumn && <th className={tableHeadCellClass}>{t('common.actions', 'Actions')}</th>}
- </tr>
- </thead>
- <tbody>
- {filteredTransactions.map((tx) => {
- const positive = tx.amount >= 0;
- const txCostCenter = tx.cost_center_id == null
- ? null
- : (costCenters || []).find((center) => center.id === tx.cost_center_id);
- // No user: deleted since, or with auth off never had one.
- const txUser = tx.user_id == null
- ? t('finance.noUser', 'No user')
- : usersById.get(tx.user_id) || t('finance.userWithId', 'User #{{id}}', { id: tx.user_id });
- const parsed = tx.transaction_type === 'print_charge'
- ? parsePrintChargeDescription((tx as { description: string | null }).description ?? null)
- : null;
- const txIsPartialPrint = parsed?.isPartial ?? false;
- return (
- <tr key={tx.id} className="border-b border-bambu-dark-tertiary/60 text-white">
- <td className={tableCellClass}>{formatTimestamp(tx.created_at, i18n.language)}</td>
- {showCostCenterAccountColumn && (
- <td className={tableCellClass}>{txUser}</td>
- )}
- <td className={tableCellClass}>{txCostCenter?.name || '-'}</td>
- <td className={tableCellClass}>
- <span className="inline-flex items-center gap-2">
- <span>{getTransactionTypeLabel(tx.transaction_type)}</span>
- {txIsPartialPrint && (
- <span className="rounded border border-yellow-500/40 bg-yellow-500/10 px-2 py-0.5 text-[11px] text-yellow-300">
- {parsed && parsed.partialType
- ? t(`finance.partialStatus.${parsed.partialType}`, parsed.partialType)
- : t('finance.partial', 'Partial')}
- </span>
- )}
- </span>
- </td>
- <td className={tableCellClass}>{(parsed?.cleanedDescription ?? tx.description) || '-'}</td>
- <td className={`${tableCellClass} ${positive ? 'text-green-400' : 'text-red-400'}`}>
- {positive ? '+' : '-'}{currencySymbol}{Math.abs(tx.amount).toFixed(2)}
- </td>
- <td className={tableCellClass}>{
- tx.balance_after == null
- ? '-'
- : `${tx.balance_after < 0 ? '-' : ''}${currencySymbol}${Math.abs(tx.balance_after).toFixed(2)}`
- }</td>
- {showCostCenterAccountColumn && (
- <td className={tableCellClass}>
- <div className="flex items-center gap-2">
- <Button
- size="sm"
- variant="secondary"
- onClick={() => handleEditTransaction(tx)}
- disabled={editTransactionMutation.isPending}
- >
- <Pencil className="w-4 h-4" />
- </Button>
- <Button
- size="sm"
- variant="danger"
- onClick={() => setPendingDeleteTransactionId(tx.id)}
- disabled={deleteTransactionMutation.isPending}
- >
- <Trash2 className="w-4 h-4" />
- </Button>
- </div>
- </td>
- )}
- </tr>
- );
- })}
- </tbody>
- </table>
- </div>
- )}
- {!txLoading && filteredTransactions.length > 0 && (
- <div className="flex justify-end pt-2 lg:pt-0">
- <div className="flex flex-wrap items-center justify-end gap-2">
- <button
- type="button"
- onClick={() => setTxOffset(0)}
- disabled={txOffset === 0 || txLoading}
- className="p-1.5 rounded text-bambu-gray hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
- aria-label={t('finance.first', 'First')}
- >
- <ChevronsLeft className="w-4 h-4" />
- </button>
- <button
- type="button"
- onClick={() => setTxOffset((prev) => Math.max(0, prev - txLimit))}
- disabled={txOffset === 0 || txLoading}
- className="p-1.5 rounded text-bambu-gray hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
- aria-label={t('finance.prev', 'Previous')}
- >
- <ChevronLeft className="w-4 h-4" />
- </button>
- <span className="text-bambu-gray px-1 whitespace-nowrap">
- {t('finance.pageNumberOf', 'Page {{page}} of {{total}}', { page: txPage, total: txTotalPages })}
- </span>
- <button
- type="button"
- onClick={() => setTxOffset((prev) => prev + txLimit)}
- disabled={txLoading || txPage >= txTotalPages}
- className="p-1.5 rounded text-bambu-gray hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
- aria-label={t('finance.next', 'Next')}
- >
- <ChevronRight className="w-4 h-4" />
- </button>
- <button
- type="button"
- onClick={() => setTxOffset(Math.max(0, (txTotalPages - 1) * txLimit))}
- disabled={txLoading || txPage >= txTotalPages}
- className="p-1.5 rounded text-bambu-gray hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
- aria-label={t('finance.last', 'Last')}
- >
- <ChevronsRight className="w-4 h-4" />
- </button>
- </div>
- </div>
- )}
- </CardContent>
- </Card>
- )}
- {showEditTransactionModal && selectedEditTransactionId !== null && (
- <FinanceModal
- title={t('finance.editTransaction', 'Edit Transaction')}
- onClose={() => setShowEditTransactionModal(false)}
- size="md"
- >
- <div className="space-y-4">
- <div>
- <label className={labelClass}>{t('common.user', 'User')}</label>
- <select
- className={fieldClass}
- value={editTransactionUserId || ''}
- onChange={(e) => setEditTransactionUserId(e.target.value ? Number(e.target.value) : null)}
- >
- <option value="">{t('finance.selectUser', 'Select user...')}</option>
- {sortedUsers.map((u) => (
- <option key={u.id} value={u.id}>{u.username}</option>
- ))}
- </select>
- </div>
- <div>
- <label className={labelClass}>{t('finance.costCenter', 'Cost center (optional)')}</label>
- <select
- className={fieldClass}
- value={editTransactionCostCenterId || ''}
- onChange={(e) => setEditTransactionCostCenterId(e.target.value ? Number(e.target.value) : null)}
- >
- <option value="">{t('finance.noCostCenter', 'Personal (no cost center)')}</option>
- {(costCenters || []).map((cc) => (
- <option key={cc.id} value={cc.id}>{cc.name}</option>
- ))}
- </select>
- </div>
- <div>
- <label className={labelClass}>{t('finance.amount', 'Amount')}</label>
- <input
- type="number"
- step="0.01"
- className={fieldClass}
- value={editTransactionAmount}
- onChange={(e) => setEditTransactionAmount(e.target.value)}
- placeholder={t('finance.amountExample', 'e.g., 10.50')}
- />
- </div>
- <div>
- <label className={labelClass}>{t('common.description', 'Description')}</label>
- <textarea
- className={`${fieldClass} resize-none`}
- rows={3}
- value={editTransactionDescription}
- onChange={(e) => setEditTransactionDescription(e.target.value)}
- placeholder={t('finance.manualAdjustmentExample', 'e.g., Manual adjustment')}
- />
- </div>
- <div className="flex justify-end gap-2 pt-4">
- <Button
- variant="secondary"
- onClick={() => setShowEditTransactionModal(false)}
- >
- {t('common.cancel', 'Cancel')}
- </Button>
- <Button
- onClick={handleSaveEditTransaction}
- disabled={editTransactionMutation.isPending}
- >
- {t('common.save', 'Save')}
- </Button>
- </div>
- </div>
- </FinanceModal>
- )}
- {showManualPrintModal && (
- <FinanceModal
- title={t('finance.addManualPrint', 'Add manual print')}
- onClose={() => setShowManualPrintModal(false)}
- size="md"
- >
- <div className="space-y-4">
- <div>
- <label className={labelClass}>{t('common.user', 'User')}</label>
- <select
- className={fieldClass}
- value={manualPrintUserId || ''}
- onChange={(e) => setManualPrintUserId(e.target.value ? Number(e.target.value) : null)}
- >
- <option value="">{t('finance.selectUser', 'Select user...')}</option>
- {sortedUsers.map((u) => (
- <option key={u.id} value={u.id}>{u.username}</option>
- ))}
- </select>
- </div>
- <div>
- <label className={labelClass}>{t('finance.costCenter', 'Cost center')}</label>
- <select
- className={fieldClass}
- value={manualPrintCostCenterId || ''}
- onChange={(e) => setManualPrintCostCenterId(e.target.value ? Number(e.target.value) : null)}
- >
- <option value="">{t('finance.selectCostCenter', 'Select cost center...')}</option>
- {(costCenters || []).map((cc) => (
- <option key={cc.id} value={cc.id}>{cc.name}</option>
- ))}
- </select>
- </div>
- <div>
- <label className={labelClass}>{t('finance.amount', 'Amount')}</label>
- <input
- type="number"
- step="0.01"
- className={fieldClass}
- value={manualPrintAmount}
- onChange={(e) => setManualPrintAmount(e.target.value)}
- placeholder={t('finance.amountExample', 'e.g., 4.00')}
- />
- </div>
- <div>
- <label className={labelClass}>{t('common.description', 'Description')}</label>
- <input
- type="text"
- value={manualPrintDescription}
- onChange={(e) => setManualPrintDescription(e.target.value)}
- placeholder={t('finance.descriptionOptional', 'Description (optional)')}
- className={fieldClass}
- />
- </div>
- <div>
- <label className={labelClass}>{t('common.date', 'Date')}</label>
- <input
- type="datetime-local"
- value={manualPrintDate}
- onChange={(e) => setManualPrintDate(e.target.value)}
- className={fieldClass}
- />
- </div>
- <div className="flex justify-end gap-2 pt-4">
- <Button variant="secondary" onClick={() => setShowManualPrintModal(false)}>
- {t('common.cancel', 'Cancel')}
- </Button>
- <Button onClick={handleSaveManualPrint} disabled={manualPrintMutation.isPending}>
- {manualPrintMutation.isPending ? t('common.saving', 'Saving...') : t('finance.addManualPrint', 'Add manual print')}
- </Button>
- </div>
- </div>
- </FinanceModal>
- )}
- {pendingDeleteCenter && (
- <ConfirmModal
- title={t('common.delete', 'Delete')}
- message={t('finance.confirmDeleteCostCenter', 'Delete cost center "{{name}}"?', {
- name: pendingDeleteCenter.name,
- })}
- confirmText={t('common.delete', 'Delete')}
- variant="danger"
- isLoading={deleteCostCenterMutation.isPending}
- onConfirm={confirmDeleteCenter}
- onCancel={() => setPendingDeleteCenter(null)}
- />
- )}
- {pendingDeleteTransactionId != null && (
- <ConfirmModal
- title={t('finance.deleteTransaction', 'Delete transaction')}
- message={t(
- 'finance.deleteTransactionConfirm',
- 'Delete this transaction? Balances will be recalculated automatically.',
- )}
- confirmText={t('common.delete', 'Delete')}
- variant="danger"
- isLoading={deleteTransactionMutation.isPending}
- onConfirm={confirmDeleteTransaction}
- onCancel={() => setPendingDeleteTransactionId(null)}
- />
- )}
- </div>
- </div>
- );
- }
|