From 8b721855408c18fa3996d551b96463ec8c146822 Mon Sep 17 00:00:00 2001 From: Gustavo Miranda Date: Thu, 23 Jul 2026 00:07:47 -0300 Subject: [PATCH 1/4] feat: add select component --- apps/frontend-v2/src/app.tsx | 41 ++++++++++++++++++--------------- apps/frontend-v2/src/i18n.ts | 4 ++-- apps/frontend-v2/src/select.tsx | 40 ++++++++++++++++++++++++++++++++ apps/frontend-v2/src/styles.css | 29 +++++++++++++++-------- apps/frontend-v2/src/ui.tsx | 9 ++++++-- 5 files changed, 92 insertions(+), 31 deletions(-) create mode 100644 apps/frontend-v2/src/select.tsx diff --git a/apps/frontend-v2/src/app.tsx b/apps/frontend-v2/src/app.tsx index a36ffe9..0118156 100644 --- a/apps/frontend-v2/src/app.tsx +++ b/apps/frontend-v2/src/app.tsx @@ -17,13 +17,15 @@ import { billsService } from "./api/bills/bills.service"; import type { Expense, ExpenseCategory, ExpenseStatus } from "./api/expenses/expenses.types"; import { expensesService } from "./api/expenses/expenses.service"; import { organizationsService } from "./api/organizations/organizations.service"; +import type { OrganizationDetails } from "./api/organizations/organizations.types"; import { useAuth } from "./auth/auth-provider"; import { useOrganizationStore } from "./auth/auth-store"; import { formatCurrency, formatDate, formatLongDate, inputDate, relativeDate } from "./format"; import { useAccountMutations, useBillMutations, useBillsQuery, useBudgetQuery, useDashboardQueries, useExpenseMutations, useExpensesQuery, useNotificationMutations, useNotificationPreferencesQuery, useOrganizationMemberMutations, useOrganizationMutations, useOrganizationQuery, useBillQuery, useExpenseQuery } from "./hooks/use-queries"; import { useDebounce } from "./hooks/use-debounce"; import { useTheme } from "./hooks/use-theme"; -import { ActionMenu, AppShell, Avatar, Button, DataIcon, EmptyState, IconButton, KpiSparkline, MetricCard, Modal, PageContainer, PageHeader, PeriodPicker, PublicLayout, QuickAction, SectionHeading, StatusPill } from "./ui"; +import { ActionMenu, AppShell, Avatar, Button, ConfirmDialog, DataIcon, EmptyState, IconButton, KpiSparkline, MetricCard, Modal, PageContainer, PageHeader, PeriodPicker, PublicLayout, QuickAction, SectionHeading, StatusPill } from "./ui"; +import { Select } from "./select"; import type { DashboardBill, DashboardExpense } from "./api/dashboard/dashboard.types"; const categoryLabels: Record = { @@ -203,7 +205,7 @@ function CategoryBar({ label, value, color }: { label: string; value: number; co function CashflowTimeline({ bills, expenses, locale }: { bills: DashboardBill[]; expenses: DashboardExpense[]; locale: string }) { const { t } = useTranslation(); const events = [...bills.map((bill) => ({ date: bill.dueDate, label: bill.description, amount: bill.amountDue, kind: "bill" as const })), ...expenses.slice(0, 3).map((expense) => ({ date: expense.date, label: expense.description, amount: expense.amount, kind: "expense" as const }))].sort((a, b) => a.date.localeCompare(b.date)); return

{t("dashboard.flow")}

{t("dashboard.flowBody")}

{t("dashboard.timelineCommitments")} {t("dashboard.timelineMoved")}
{events.map((event, index) =>
{formatDate(event.date, locale)}{event.label}{event.kind === "expense" ? "−" : ""}{formatCurrency(event.amount, locale)}
)}
{!events.length && }
; } function BillsPage() { - const { t } = useTranslation(); const locale = useLocale(); const organizationId = useSelectedOrganization(); const month = useCurrentMonth(); const [search, setSearch] = useState(""); const debouncedSearch = useDebounce(search); const [status, setStatus] = useState("all"); const [series, setSeries] = useState("all"); const [page, setPage] = useState(1); const [modal, setModal] = useState<"add" | "edit" | null>(null); const [selected, setSelected] = useState(null); const query = useBillsQuery(organizationId ? { organizationId, page, pageSize: 20, from: month.from, to: month.to, status: status === "all" ? undefined : status, description: debouncedSearch } : null); const mutations = useBillMutations(organizationId); const rows = (query.data?.data ?? []).filter((bill) => series === "all" || bill.billSeriesType === series); const total = rows.reduce((sum, bill) => sum + bill.amountDue, 0); const due = rows.filter((bill) => ["due", "overdue"].includes(bill.status)).reduce((sum, bill) => sum + bill.amountDue, 0); const paid = rows.filter((bill) => bill.status === "paid").reduce((sum, bill) => sum + (bill.amountPaid ?? 0), 0); const clear = () => { setSearch(""); setStatus("all"); setSeries("all"); setPage(1); }; const remove = (id: string) => { if (window.confirm(t("common.deleteBillConfirm"))) mutations.remove.mutate(id, { onSuccess: () => toast.success(t("bills.removed")), onError: (error) => toast.error(error.message) }); }; const markPaid = (bill: Bill) => mutations.update.mutate({ id: bill.id, input: { description: bill.description, category: bill.category, status: "paid", dueDate: bill.dueDate, paymentDate: new Date().toISOString(), amountDue: bill.amountDue, amountPaid: bill.amountDue } }, { onSuccess: () => toast.success(t("bills.markedPaid")), onError: (error) => toast.error(error.message) }); + const { t } = useTranslation(); const locale = useLocale(); const organizationId = useSelectedOrganization(); const month = useCurrentMonth(); const [search, setSearch] = useState(""); const debouncedSearch = useDebounce(search); const [status, setStatus] = useState("all"); const [series, setSeries] = useState("all"); const [page, setPage] = useState(1); const [modal, setModal] = useState<"add" | "edit" | null>(null); const [selected, setSelected] = useState(null); const query = useBillsQuery(organizationId ? { organizationId, page, pageSize: 20, from: month.from, to: month.to, status: status === "all" ? undefined : status, description: debouncedSearch } : null); const mutations = useBillMutations(organizationId); const rows = (query.data?.data ?? []).filter((bill) => series === "all" || bill.billSeriesType === series); const total = rows.reduce((sum, bill) => sum + bill.amountDue, 0); const due = rows.filter((bill) => ["due", "overdue"].includes(bill.status)).reduce((sum, bill) => sum + bill.amountDue, 0); const paid = rows.filter((bill) => bill.status === "paid").reduce((sum, bill) => sum + (bill.amountPaid ?? 0), 0); const clear = () => { setSearch(""); setStatus("all"); setSeries("all"); setPage(1); }; const [pendingRemoval, setPendingRemoval] = useState(null); const confirmRemoval = () => { if (!pendingRemoval) return; mutations.remove.mutate(pendingRemoval.id, { onSuccess: () => { setPendingRemoval(null); toast.success(t("bills.removed")); }, onError: (error) => toast.error(error.message) }); }; const markPaid = (bill: Bill) => mutations.update.mutate({ id: bill.id, input: { description: bill.description, category: bill.category, status: "paid", dueDate: bill.dueDate, paymentDate: new Date().toISOString(), amountDue: bill.amountDue, amountPaid: bill.amountDue } }, { onSuccess: () => toast.success(t("bills.markedPaid")), onError: (error) => toast.error(error.message) }); const upload = async (bill: Bill, files: File[]) => { if (files.some((file) => !isAcceptedDocument(file))) { toast.error(t("bills.invalidFile")); @@ -219,7 +221,7 @@ function BillsPage() { }; if (!organizationId) return ; - return setModal("add")}> {t("bills.add")}} />
{t("bills.total")}{formatCurrency(total, locale)}
{t("bills.due")}{formatCurrency(due, locale)}
{t("bills.paid")}{formatCurrency(paid, locale)}
{(search || status !== "all" || series !== "all") && }
{query.isPending ? : query.error ? { void query.refetch(); }} /> : <>
{t("bills.commitment")}{t("bills.dueDate")}{t("bills.type")}{t("common.amount")}{t("bills.status")}
{rows.length ? rows.map((bill) => { setSelected(bill); setModal("edit"); }} onDelete={() => remove(bill.id)} onPaid={() => markPaid(bill)} onUpload={(files) => upload(bill, files)} />) : {t("common.clearFilters")}} />}}
{query.data && query.data.totalPages > 1 && }{modal === "add" && setModal(null)} organizationId={organizationId} />}{modal === "edit" && selected && { setModal(null); setSelected(null); }} organizationId={organizationId} />}
; + return setModal("add")}> {t("bills.add")}} />
{t("bills.total")}{formatCurrency(total, locale)}
{t("bills.due")}{formatCurrency(due, locale)}
{t("bills.paid")}{formatCurrency(paid, locale)}
ariaLabel={t("bills.status")} value={status} onValueChange={(value) => { setStatus(value); setPage(1); }} options={[{ value: "all", label: t("bills.allStatuses") }, ...(["upcoming", "due", "overdue", "paid", "cancelled"] as BillStatus[]).map((value) => ({ value, label: t(`statuses.${value}`) }))]} />
ariaLabel={t("bills.type")} value={series} onValueChange={setSeries} options={[{ value: "all", label: t("bills.allTypes") }, { value: "recurring", label: t("bills.recurring") }, { value: "installment", label: t("bills.installments") }]} />
{(search || status !== "all" || series !== "all") && }
{query.isPending ? : query.error ? { void query.refetch(); }} /> : <>
{t("bills.commitment")}{t("bills.dueDate")}{t("bills.type")}{t("common.amount")}{t("bills.status")}
{rows.length ? rows.map((bill) => { setSelected(bill); setModal("edit"); }} onDelete={() => setPendingRemoval(bill)} onPaid={() => markPaid(bill)} onUpload={(files) => upload(bill, files)} />) : {t("common.clearFilters")}} />}}
{query.data && query.data.totalPages > 1 && }{modal === "add" && setModal(null)} organizationId={organizationId} />}{modal === "edit" && selected && { setModal(null); setSelected(null); }} organizationId={organizationId} />}{pendingRemoval && setPendingRemoval(null)} />}
; } function Pagination({ page, totalPages, onPageChange }: { page: number; totalPages: number; onPageChange: (page: number) => void }) { const { t } = useTranslation(); return
{page} / {totalPages}
; } @@ -290,9 +292,9 @@ function BillRow({ ); } -function BillModal({ bill, onClose, organizationId }: { bill?: Bill; onClose: () => void; organizationId: string }) { const { t } = useTranslation(); const mutations = useBillMutations(organizationId); const submit = (event: FormEvent) => { event.preventDefault(); const data = new FormData(event.currentTarget); const series = String(data.get("series") ?? "one-time"); const input = { description: String(data.get("description") ?? ""), category: String(data.get("category") ?? "miscellaneous") as BillCategory, status: (bill?.status ?? "upcoming") as BillStatus, dueDate: new Date(`${String(data.get("date"))}T12:00:00.000Z`).toISOString(), paymentDate: bill?.paymentDate ?? null, amountDue: Number(data.get("amount") ?? 0), amountPaid: bill?.amountPaid ?? null, frequency: series === "one-time" ? null : String(data.get("frequency") ?? "monthly") as BillFrequency, installments: series === "installment" ? Number(data.get("installments") ?? 1) : null }; const done = () => { toast.success(bill ? t("bills.updated") : t("bills.created")); onClose(); }; if (bill) mutations.update.mutate({ id: bill.id, input }, { onSuccess: done, onError: (error) => toast.error(error.message) }); else mutations.create.mutate(input, { onSuccess: done, onError: (error) => toast.error(error.message) }); }; return
{!bill && <>}
; } +function BillModal({ bill, onClose, organizationId }: { bill?: Bill; onClose: () => void; organizationId: string }) { const { t } = useTranslation(); const mutations = useBillMutations(organizationId); const submit = (event: FormEvent) => { event.preventDefault(); const data = new FormData(event.currentTarget); const series = String(data.get("series") ?? "one-time"); const input = { description: String(data.get("description") ?? ""), category: String(data.get("category") ?? "miscellaneous") as BillCategory, status: (bill?.status ?? "upcoming") as BillStatus, dueDate: new Date(`${String(data.get("date"))}T12:00:00.000Z`).toISOString(), paymentDate: bill?.paymentDate ?? null, amountDue: Number(data.get("amount") ?? 0), amountPaid: bill?.amountPaid ?? null, frequency: series === "one-time" ? null : String(data.get("frequency") ?? "monthly") as BillFrequency, installments: series === "installment" ? Number(data.get("installments") ?? 1) : null }; const done = () => { toast.success(bill ? t("bills.updated") : t("bills.created")); onClose(); }; if (bill) mutations.update.mutate({ id: bill.id, input }, { onSuccess: done, onError: (error) => toast.error(error.message) }); else mutations.create.mutate(input, { onSuccess: done, onError: (error) => toast.error(error.message) }); }; return
{!bill && <>}
; } -function BillDetailsPage() { const { t } = useTranslation(); const { billId } = useParams(); const organizationId = useSelectedOrganization(); const locale = useLocale(); const query = useBillQuery(organizationId, billId); const mutations = useBillMutations(organizationId); const inputRef = useRef(null); const [fileCategory, setFileCategory] = useState("Other"); const bill = query.data; const upload = async (files: File[]) => { +function BillDetailsPage() { const { t } = useTranslation(); const { billId } = useParams(); const organizationId = useSelectedOrganization(); const locale = useLocale(); const query = useBillQuery(organizationId, billId); const mutations = useBillMutations(organizationId); const inputRef = useRef(null); const [fileCategory, setFileCategory] = useState("Other"); const [stopSeriesConfirm, setStopSeriesConfirm] = useState(false); const [pendingDocumentId, setPendingDocumentId] = useState(null); const bill = query.data; const upload = async (files: File[]) => { if (files.some((file) => !isAcceptedDocument(file))) { toast.error(t("bills.invalidFile")); return; @@ -308,12 +310,12 @@ function BillDetailsPage() { const { t } = useTranslation(); const { billId } = } catch (error) { toast.error(error instanceof Error ? error.message : t("bills.invalidFile")); } - }; const open = async (documentId: string) => { const blob = await billsService.getDocumentAsync(organizationId!, billId!, documentId); const url = URL.createObjectURL(blob); window.open(url, "_blank", "noopener,noreferrer"); window.setTimeout(() => URL.revokeObjectURL(url), 30_000); }; const download = async (documentId: string) => { const response = await billsService.getDocumentDownloadUrlAsync(organizationId!, billId!, documentId); window.open(response.url, "_blank", "noopener,noreferrer"); }; if (query.isPending) return ; if (query.error || !bill) return { void query.refetch(); }} />; return ← {t("nav.bills")} mutations.update.mutate({ id: bill.id, input: { description: bill.description, category: bill.category, status: "paid", dueDate: bill.dueDate, paymentDate: new Date().toISOString(), amountDue: bill.amountDue, amountPaid: bill.amountDue } }, { onSuccess: () => toast.success(t("bills.markedPaid")), onError: (error) => toast.error(error.message) })}>{t("bills.markPaid")} : null} />
{t("bills.amountDue")}{formatCurrency(bill.amountDue, locale)}
{t("bills.dueDate")}
{formatLongDate(bill.dueDate, locale)}
{t("common.category")}
{t(categoryLabels[bill.category])}
{t("common.schedule")}
{bill.billSeriesType ? `${t(`types.${bill.billSeriesType}`)}${bill.billSeriesFrequency ? ` · ${t(`bills.${bill.billSeriesFrequency}`)}` : ""}` : t("common.oneTime")}
{bill.billSeriesId && bill.billSeriesIsActive && }
{ const files = Array.from(event.target.files ?? []); if (files.length > 0) void upload(files); event.currentTarget.value = ""; }} />
} />{bill.documents.length ? bill.documents.map((document) =>
{document.fileName}{t(`documents.${document.fileCategory}`)} { if (window.confirm(t("common.removeDocumentConfirm"))) mutations.removeDocument.mutate({ billId: bill.id, documentId: document.id }, { onSuccess: () => toast.success(t("bills.documentRemoved")), onError: (error) => toast.error(error.message) }); }}>
) : }
; } + }; const open = async (documentId: string) => { const blob = await billsService.getDocumentAsync(organizationId!, billId!, documentId); const url = URL.createObjectURL(blob); window.open(url, "_blank", "noopener,noreferrer"); window.setTimeout(() => URL.revokeObjectURL(url), 30_000); }; const download = async (documentId: string) => { const response = await billsService.getDocumentDownloadUrlAsync(organizationId!, billId!, documentId); window.open(response.url, "_blank", "noopener,noreferrer"); }; if (query.isPending) return ; if (query.error || !bill) return { void query.refetch(); }} />; return ← {t("nav.bills")} mutations.update.mutate({ id: bill.id, input: { description: bill.description, category: bill.category, status: "paid", dueDate: bill.dueDate, paymentDate: new Date().toISOString(), amountDue: bill.amountDue, amountPaid: bill.amountDue } }, { onSuccess: () => toast.success(t("bills.markedPaid")), onError: (error) => toast.error(error.message) })}>{t("bills.markPaid")} : null} />
{t("bills.amountDue")}{formatCurrency(bill.amountDue, locale)}
{t("bills.dueDate")}
{formatLongDate(bill.dueDate, locale)}
{t("common.category")}
{t(categoryLabels[bill.category])}
{t("common.schedule")}
{bill.billSeriesType ? `${t(`types.${bill.billSeriesType}`)}${bill.billSeriesFrequency ? ` · ${t(`bills.${bill.billSeriesFrequency}`)}` : ""}` : t("common.oneTime")}
{bill.billSeriesId && bill.billSeriesIsActive && }
{ const files = Array.from(event.target.files ?? []); if (files.length > 0) void upload(files); event.currentTarget.value = ""; }} />
} />{bill.documents.length ? bill.documents.map((document) =>
{document.fileName}{t(`documents.${document.fileCategory}`)} setPendingDocumentId(document.id)}>
) : }
{stopSeriesConfirm && bill.billSeriesId && mutations.stopSeries.mutate(bill.billSeriesId!, { onSuccess: () => { setStopSeriesConfirm(false); toast.success(t("bills.futureStopped")); }, onError: (error) => toast.error(error.message) })} onClose={() => setStopSeriesConfirm(false)} />}{pendingDocumentId && mutations.removeDocument.mutate({ billId: bill.id, documentId: pendingDocumentId }, { onSuccess: () => { setPendingDocumentId(null); toast.success(t("bills.documentRemoved")); }, onError: (error) => toast.error(error.message) })} onClose={() => setPendingDocumentId(null)} />}
; } -function ExpensesPage() { const { t } = useTranslation(); const locale = useLocale(); const organizationId = useSelectedOrganization(); const month = useCurrentMonth(); const { user } = useAuth(); const [search, setSearch] = useState(""); const [status, setStatus] = useState("all"); const [page, setPage] = useState(1); const [modal, setModal] = useState<"add" | "edit" | null>(null); const [selected, setSelected] = useState(null); const query = useExpensesQuery(organizationId ? { organizationId, page, pageSize: 20, from: month.from, to: month.to } : null); const mutations = useExpenseMutations(organizationId); const rows = (query.data?.data ?? []).filter((expense) => expense.description.toLowerCase().includes(search.toLowerCase()) && (status === "all" || expense.status === status)); const total = rows.reduce((sum, expense) => sum + expense.amount, 0); const remove = (id: string) => { if (window.confirm(t("common.deleteExpenseConfirm"))) mutations.remove.mutate(id, { onSuccess: () => toast.success(t("expenses.removed")), onError: (error) => toast.error(error.message) }); }; if (!organizationId || !user) return ; return setModal("add")}> {t("expenses.add")}} />
{t("expenses.total")}{formatCurrency(total, locale)}
{t("expenses.transactions")}{rows.length}
{t("expenses.average")}{formatCurrency(rows.length ? total / rows.length : 0, locale)}

{t("expenses.localFilters")}

{query.isPending ? : query.error ? { void query.refetch(); }} /> : <>
{t("expenses.expense")}{t("expenses.date")}{t("expenses.category")}{t("expenses.amount")}{t("expenses.status")}
{rows.length ? rows.map((expense) => { setSelected(expense); setModal("edit"); }} onDelete={() => remove(expense.id)} />) : }}
{query.data && query.data.totalPages > 1 && }{modal && { setModal(null); setSelected(null); }} organizationId={organizationId} userId={user.id} />}
; } +function ExpensesPage() { const { t } = useTranslation(); const locale = useLocale(); const organizationId = useSelectedOrganization(); const month = useCurrentMonth(); const { user } = useAuth(); const [search, setSearch] = useState(""); const [status, setStatus] = useState("all"); const [page, setPage] = useState(1); const [modal, setModal] = useState<"add" | "edit" | null>(null); const [selected, setSelected] = useState(null); const query = useExpensesQuery(organizationId ? { organizationId, page, pageSize: 20, from: month.from, to: month.to } : null); const mutations = useExpenseMutations(organizationId); const rows = (query.data?.data ?? []).filter((expense) => expense.description.toLowerCase().includes(search.toLowerCase()) && (status === "all" || expense.status === status)); const total = rows.reduce((sum, expense) => sum + expense.amount, 0); const [pendingRemoval, setPendingRemoval] = useState(null); const confirmRemoval = () => { if (!pendingRemoval) return; mutations.remove.mutate(pendingRemoval.id, { onSuccess: () => { setPendingRemoval(null); toast.success(t("expenses.removed")); }, onError: (error) => toast.error(error.message) }); }; if (!organizationId || !user) return ; return setModal("add")}> {t("expenses.add")}} />
{t("expenses.total")}{formatCurrency(total, locale)}
{t("expenses.transactions")}{rows.length}
{t("expenses.average")}{formatCurrency(rows.length ? total / rows.length : 0, locale)}

{t("expenses.localFilters")}

ariaLabel={t("expenses.status")} value={status} onValueChange={setStatus} options={[{ value: "all", label: t("expenses.allStatuses") }, ...(["paid", "pending", "cancelled"] as ExpenseStatus[]).map((value) => ({ value, label: t(`statuses.${value}`) }))]} />
{query.isPending ? : query.error ? { void query.refetch(); }} /> : <>
{t("expenses.expense")}{t("expenses.date")}{t("expenses.category")}{t("expenses.amount")}{t("expenses.status")}
{rows.length ? rows.map((expense) => { setSelected(expense); setModal("edit"); }} onDelete={() => setPendingRemoval(expense)} />) : }}
{query.data && query.data.totalPages > 1 && }{modal && { setModal(null); setSelected(null); }} organizationId={organizationId} userId={user.id} />}{pendingRemoval && setPendingRemoval(null)} />}
; } function ExpenseRow({ expense, locale, onDetails, onDelete }: { expense: Expense; locale: string; onDetails: () => void; onDelete: () => void }) { const { t } = useTranslation(); return
{expense.description}{t("expenses.added", { date: relativeDate(expense.occurredAt, locale) })}
{formatDate(expense.occurredAt, locale)}{t(categoryLabels[expense.category])}{formatCurrency(expense.amount, locale)}
; } -function ExpenseModal({ expense, onClose, organizationId, userId }: { expense?: Expense; onClose: () => void; organizationId: string; userId: string }) { const { t } = useTranslation(); const mutations = useExpenseMutations(organizationId); const submit = (event: FormEvent) => { event.preventDefault(); const data = new FormData(event.currentTarget); const input = { description: String(data.get("description") ?? ""), category: String(data.get("category") ?? "miscellaneous") as ExpenseCategory, amount: Number(data.get("amount") ?? 0), status: (expense?.status ?? "paid") as ExpenseStatus, occurredAt: new Date(`${String(data.get("date"))}T12:00:00.000Z`).toISOString() }; const done = () => { toast.success(expense ? t("expenses.updated") : t("expenses.created")); onClose(); }; if (expense) mutations.update.mutate({ id: expense.id, input }, { onSuccess: done, onError: (error) => toast.error(error.message) }); else mutations.create.mutate({ ...input, createdBy: userId }, { onSuccess: done, onError: (error) => toast.error(error.message) }); }; return
; } -function ExpenseDetailsPage() { const { t } = useTranslation(); const { expenseId } = useParams(); const organizationId = useSelectedOrganization(); const locale = useLocale(); const query = useExpenseQuery(organizationId, expenseId); const mutations = useExpenseMutations(organizationId); const inputRef = useRef(null); const [fileCategory, setFileCategory] = useState("Receipt"); const expense = query.data; const upload = (file: File) => { const valid = acceptedDocumentTypes.split(",").some((type) => file.name.toLowerCase().endsWith(type.replace(".", ""))) && file.size <= 10 * 1024 * 1024; if (!valid) { toast.error(t("bills.invalidFile")); return; } if (expenseId) mutations.upload.mutate({ id: expenseId, file, category: fileCategory }, { onSuccess: () => toast.success(t("bills.uploaded")), onError: (error) => toast.error(error.message) }); }; const open = async (documentId: string) => { const blob = await expensesService.getDocumentAsync(organizationId!, expenseId!, documentId); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = "expense-document"; link.click(); window.setTimeout(() => URL.revokeObjectURL(url), 30_000); }; if (query.isPending) return ; if (query.error || !expense) return { void query.refetch(); }} />; return ← {t("nav.expenses")}
{t("expenses.amount")}{formatCurrency(expense.amount, locale)}
{t("expenses.occurred")}
{formatLongDate(expense.occurredAt, locale)}
{t("common.category")}
{t(categoryLabels[expense.category])}
{t("expenses.createdBy")}
{expense.createdBy}
{ const file = event.target.files?.[0]; if (file) upload(file); event.currentTarget.value = ""; }} />
} />{expense.documents.length ? expense.documents.map((document) =>
{document.fileName}{t(`documents.${document.fileCategory}`)} { if (window.confirm(t("common.removeDocumentConfirm"))) mutations.removeDocument.mutate({ expenseId: expense.id, attachmentId: document.id }, { onSuccess: () => toast.success(t("bills.documentRemoved")), onError: (error) => toast.error(error.message) }); }}>
) : }
; } +function ExpenseModal({ expense, onClose, organizationId, userId }: { expense?: Expense; onClose: () => void; organizationId: string; userId: string }) { const { t } = useTranslation(); const mutations = useExpenseMutations(organizationId); const submit = (event: FormEvent) => { event.preventDefault(); const data = new FormData(event.currentTarget); const input = { description: String(data.get("description") ?? ""), category: String(data.get("category") ?? "miscellaneous") as ExpenseCategory, amount: Number(data.get("amount") ?? 0), status: (expense?.status ?? "paid") as ExpenseStatus, occurredAt: new Date(`${String(data.get("date"))}T12:00:00.000Z`).toISOString() }; const done = () => { toast.success(expense ? t("expenses.updated") : t("expenses.created")); onClose(); }; if (expense) mutations.update.mutate({ id: expense.id, input }, { onSuccess: done, onError: (error) => toast.error(error.message) }); else mutations.create.mutate({ ...input, createdBy: userId }, { onSuccess: done, onError: (error) => toast.error(error.message) }); }; return
; } +function ExpenseDetailsPage() { const { t } = useTranslation(); const { expenseId } = useParams(); const organizationId = useSelectedOrganization(); const locale = useLocale(); const query = useExpenseQuery(organizationId, expenseId); const mutations = useExpenseMutations(organizationId); const inputRef = useRef(null); const [fileCategory, setFileCategory] = useState("Receipt"); const [pendingDocumentId, setPendingDocumentId] = useState(null); const expense = query.data; const upload = (file: File) => { const valid = acceptedDocumentTypes.split(",").some((type) => file.name.toLowerCase().endsWith(type.replace(".", ""))) && file.size <= 10 * 1024 * 1024; if (!valid) { toast.error(t("bills.invalidFile")); return; } if (expenseId) mutations.upload.mutate({ id: expenseId, file, category: fileCategory }, { onSuccess: () => toast.success(t("bills.uploaded")), onError: (error) => toast.error(error.message) }); }; const open = async (documentId: string) => { const blob = await expensesService.getDocumentAsync(organizationId!, expenseId!, documentId); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = "expense-document"; link.click(); window.setTimeout(() => URL.revokeObjectURL(url), 30_000); }; if (query.isPending) return ; if (query.error || !expense) return { void query.refetch(); }} />; return ← {t("nav.expenses")}
{t("expenses.amount")}{formatCurrency(expense.amount, locale)}
{t("expenses.occurred")}
{formatLongDate(expense.occurredAt, locale)}
{t("common.category")}
{t(categoryLabels[expense.category])}
{t("expenses.createdBy")}
{expense.createdBy}
{ const file = event.target.files?.[0]; if (file) upload(file); event.currentTarget.value = ""; }} />
} />{expense.documents.length ? expense.documents.map((document) =>
{document.fileName}{t(`documents.${document.fileCategory}`)} setPendingDocumentId(document.id)}>
) : }
{pendingDocumentId && mutations.removeDocument.mutate({ expenseId: expense.id, attachmentId: pendingDocumentId }, { onSuccess: () => { setPendingDocumentId(null); toast.success(t("bills.documentRemoved")); }, onError: (error) => toast.error(error.message) })} onClose={() => setPendingDocumentId(null)} />}
; } // The form mirrors a newly selected server record; this synchronization is intentional. // eslint-disable-next-line react-hooks/set-state-in-effect @@ -329,6 +331,7 @@ function MembersPage() { const memberMutations = useOrganizationMemberMutations(organizationId); const [inviteOpen, setInviteOpen] = useState(false); const [invite, setInvite] = useState<{ url: string; expiresAt: string } | null>(null); + const [pendingRemoval, setPendingRemoval] = useState(null); const currentMember = detail.data?.members.find((member) => member.id === auth.user?.id); const currentRole = isKnownMemberRole(currentMember?.role) ? currentMember.role : undefined; const inviteRoles: Array<"Admin" | "Member"> = currentRole === "Owner" ? ["Admin", "Member"] : ["Member"]; @@ -354,12 +357,12 @@ function MembersPage() { }; const canChangeRole = (member: typeof detail.data.members[number]) => currentRole === "Owner" && isEditableMemberRole(member.role); const canRemove = (member: typeof detail.data.members[number]) => member.id === auth.user?.id || (currentRole === "Owner" && isEditableMemberRole(member.role)) || (currentRole === "Admin" && member.role === "Member"); - const removeMember = (member: typeof detail.data.members[number]) => { - const isSelf = member.id === auth.user?.id; - const confirmed = window.confirm(isSelf ? t("common.leaveOrganizationConfirm") : t("common.removeMemberConfirm", { name: member.username })); - if (!confirmed) return; - memberMutations.remove.mutate(member.id, { + const confirmRemoval = () => { + if (!pendingRemoval) return; + const isSelf = pendingRemoval.id === auth.user?.id; + memberMutations.remove.mutate(pendingRemoval.id, { onSuccess: async () => { + setPendingRemoval(null); if (!isSelf) { toast.success(t("organization.memberRemoved")); return; @@ -387,14 +390,15 @@ function MembersPage() { {role ? t(`roles.${role}`) : t("organization.roleUnavailable")} {member.joinedAt ? t("common.joined", { date: formatLongDate(member.joinedAt, locale) }) : t("organization.joinedUnavailable")} {(canChangeRole(member) || canRemove(member)) &&
- {canChangeRole(member) && } - {canRemove(member) && } + {canChangeRole(member) && ariaLabel={`${t("organization.roleFor")} ${member.username}`} value={isEditableMemberRole(role) ? role : "Member"} disabled={memberMutations.updateRole.isPending} onValueChange={(value) => memberMutations.updateRole.mutate({ userId: member.id, role: value }, { onSuccess: () => toast.success(t("organization.roleUpdated")), onError: (error) => toast.error(error instanceof Error ? error.message : t("organization.roleError")) })} options={[{ value: "Admin", label: t("roles.Admin") }, { value: "Member", label: t("roles.Member") }]} />} + {canRemove(member) && }
} ; })} {invite &&
{ void copy(); }}>{t("common.copy")}} />
} - {inviteOpen && canInvite && setInviteOpen(false)}>
{ event.preventDefault(); const data = new FormData(event.currentTarget); createInvite.mutate({ email: String(data.get("email")), role: String(data.get("role")) as "Admin" | "Member" }); }}>
} + {inviteOpen && canInvite && setInviteOpen(false)}>
{ event.preventDefault(); const data = new FormData(event.currentTarget); createInvite.mutate({ email: String(data.get("email")), role: String(data.get("role")) as "Admin" | "Member" }); }}>