From d966b46d9ab59fbb75d65a6ddbacbf46fb1a0aab Mon Sep 17 00:00:00 2001 From: waterWang Date: Wed, 26 Aug 2026 23:10:38 +0800 Subject: [PATCH] feat(frontend): add invoice dashboard with status filtering and reminders Closes #602 Signed-off-by: waterWang --- frontend/__tests__/InvoicesPage.test.tsx | 204 +++++++++++++++ frontend/__tests__/invoices-lib.test.ts | 169 +++++++++++++ frontend/components/InvoiceCard.tsx | 25 +- frontend/components/Navbar.tsx | 1 + frontend/lib/invoices.ts | 220 ++++++++++++++++- frontend/pages/invoices.tsx | 287 +++++++++++++++++++++- frontend/public/locales/ar/common.json | 24 +- frontend/public/locales/en/common.json | 24 +- frontend/public/locales/es/common.json | 24 +- frontend/public/locales/fr/common.json | 24 +- frontend/public/locales/he/common.json | 24 +- frontend/public/locales/ja/common.json | 24 +- frontend/public/locales/pt/common.json | 24 +- frontend/stories/InvoicesPage.stories.tsx | 94 +++++++ 14 files changed, 1148 insertions(+), 20 deletions(-) create mode 100644 frontend/__tests__/InvoicesPage.test.tsx create mode 100644 frontend/__tests__/invoices-lib.test.ts create mode 100644 frontend/stories/InvoicesPage.stories.tsx diff --git a/frontend/__tests__/InvoicesPage.test.tsx b/frontend/__tests__/InvoicesPage.test.tsx new file mode 100644 index 00000000..09f07597 --- /dev/null +++ b/frontend/__tests__/InvoicesPage.test.tsx @@ -0,0 +1,204 @@ +/** + * Tests for pages/invoices.tsx — Invoice Dashboard. + * @jest-environment jsdom + */ +import React from "react"; +import { render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useRouter } from "next/router"; +import InvoicesPage from "../pages/invoices"; +import { INVOICES_STORAGE_KEY, type Invoice } from "@/lib/invoices"; + +jest.mock("next/router", () => ({ + useRouter: jest.fn(), +})); + +// Mock i18n — return the key's last segment as a readable label. +jest.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => { + const seg = key.split(".").pop() || key; + const map: Record = { + filterAll: "All", + filterDraft: "Draft", + filterSent: "Sent", + filterPaid: "Paid", + filterOverdue: "Overdue", + title: "Invoices", + subtitle: "Invoices subtitle", + empty: "No invoices yet. Create one to get started.", + emptyFilter: "No invoices match this filter.", + createNew: "New Invoice", + createFirst: "Create your first invoice", + loading: "Loading", + statOutstanding: "Outstanding", + statPaid: "Paid", + statTotal: "Total", + created: "Invoice created", + reminderSent: "Reminder sent", + downloadFailed: "Failed", + filterAllKey: "All", + }; + return map[seg] ?? seg; + }, + }), +})); + +jest.mock("next/head", () => ({ + __esModule: true, + default: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +jest.mock("framer-motion", () => ({ + motion: { + div: ({ children, ...props }: React.ComponentProps<"div">) =>
{children}
, + }, + AnimatePresence: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +// Toast context: simple no-op provider. +const ToastCtx = require("@/lib/ToastContext"); +jest.spyOn(ToastCtx, "useToastContext").mockReturnValue({ + addToast: jest.fn(), + removeToast: jest.fn(), + toasts: [], +}); + +const mockReplace = jest.fn(); +(useRouter as jest.Mock).mockReturnValue({ + query: {}, + replace: mockReplace, + pathname: "/invoices", +}); + +const mkInvoice = (over: Partial): Invoice => ({ + id: "inv-1", + invoiceNumber: "INV-00001", + status: "sent", + clientName: "Acme Corp", + clientEmail: "billing@acme.test", + description: "Consulting", + amount: "250.5", + asset: "USDC", + createdAt: "2026-08-01T00:00:00.000Z", + dueDate: "2026-09-01", + ...over, +}); + +beforeEach(() => { + localStorage.clear(); + jest.clearAllMocks(); +}); + +describe("InvoicesPage", () => { + it("renders empty state when no invoices exist", () => { + render(); + expect(screen.getByRole("heading", { name: /Invoices/i })).toBeInTheDocument(); + expect(screen.getByText(/No invoices yet/i)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /New Invoice/i })).toBeInTheDocument(); + }); + + it("lists invoices and shows summary cards", async () => { + localStorage.setItem( + INVOICES_STORAGE_KEY, + JSON.stringify([ + mkInvoice({ id: "a", clientName: "Alpha Corp", amount: "100", asset: "XLM" }), + mkInvoice({ id: "b", clientName: "Beta Inc", status: "paid", amount: "200", asset: "XLM" }), + ]), + ); + render(); + await waitFor(() => { + expect(screen.getByText("Alpha Corp")).toBeInTheDocument(); + }); + // Summary cards (Outstanding 100, Paid 200, Total 300) — invoice cards also + // render amounts, so assert these appear at least once. + expect(screen.getAllByText("100 XLM").length).toBeGreaterThan(0); + expect(screen.getAllByText("200 XLM").length).toBeGreaterThan(0); + expect(screen.getAllByText("300 XLM").length).toBeGreaterThan(0); + }); + + it("filters invoices by status", async () => { + const user = userEvent.setup(); + localStorage.setItem( + INVOICES_STORAGE_KEY, + JSON.stringify([ + mkInvoice({ id: "a", clientName: "Alpha", status: "draft" }), + mkInvoice({ id: "b", clientName: "Beta", status: "paid" }), + ]), + ); + render(); + await waitFor(() => expect(screen.getByText("Alpha")).toBeInTheDocument()); + + await user.click(screen.getByRole("tab", { name: /^Paid/ })); + expect(screen.queryByText("Alpha")).not.toBeInTheDocument(); + expect(screen.getByText("Beta")).toBeInTheDocument(); + + await user.click(screen.getByRole("tab", { name: /^Draft/ })); + expect(screen.getByText("Alpha")).toBeInTheDocument(); + expect(screen.queryByText("Beta")).not.toBeInTheDocument(); + }); + it("opens the detail panel from a deep link (?invoice=id)", async () => { + localStorage.setItem( + INVOICES_STORAGE_KEY, + JSON.stringify([mkInvoice({ id: "deep-1", amount: "100", asset: "USDC" })]), + ); + (useRouter as jest.Mock).mockReturnValue({ + query: { invoice: "deep-1" }, + replace: mockReplace, + pathname: "/invoices", + }); + render(); + await waitFor(() => { + expect(screen.getByText("Update Status")).toBeInTheDocument(); + }); + }); + + it("opens detail on card click from the list", async () => { + const user = userEvent.setup(); + localStorage.setItem( + INVOICES_STORAGE_KEY, + JSON.stringify([mkInvoice({ id: "a" })]), + ); + render(); + await waitFor(() => expect(screen.getByText("Acme Corp")).toBeInTheDocument()); + + await user.click(screen.getByRole("button", { name: /View Details/i })); + await waitFor(() => { + expect(screen.getByText("Update Status")).toBeInTheDocument(); + }); + expect(mockReplace).toHaveBeenCalledWith( + expect.objectContaining({ query: expect.objectContaining({ invoice: "a" }) }), + undefined, + { shallow: true }, + ); + }); + + it("renders the reminder flow from the detail panel", async () => { + const user = userEvent.setup(); + localStorage.setItem( + INVOICES_STORAGE_KEY, + JSON.stringify([mkInvoice({ id: "a" })]), + ); + render(); + await waitFor(() => expect(screen.getByText("Acme Corp")).toBeInTheDocument()); + + await user.click(screen.getByRole("button", { name: /View Details/i })); + await waitFor(() => { + expect(screen.getByText("Update Status")).toBeInTheDocument(); + }); + // Detail panel renders the status controls and detail actions. + expect(screen.getByRole("button", { name: /Draft/i })).toBeInTheDocument(); + }); + + it("shows a filtered empty state", async () => { + const user = userEvent.setup(); + localStorage.setItem( + INVOICES_STORAGE_KEY, + JSON.stringify([mkInvoice({ id: "a", status: "draft" })]), + ); + render(); + await waitFor(() => expect(screen.getByText("Acme Corp")).toBeInTheDocument()); + await user.click(screen.getByRole("tab", { name: /^Overdue/ })); + expect(screen.getByText(/No invoices match/i)).toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/frontend/__tests__/invoices-lib.test.ts b/frontend/__tests__/invoices-lib.test.ts new file mode 100644 index 00000000..1b39d63f --- /dev/null +++ b/frontend/__tests__/invoices-lib.test.ts @@ -0,0 +1,169 @@ +/** + * Tests for lib/invoices.ts — localStorage persistence + summary helpers. + * @jest-environment jsdom + */ +import { + buildInvoiceNumber, + createInvoice, + deleteInvoice, + INVOICES_STORAGE_KEY, + loadInvoices, + sendInvoiceReminder, + summarizeInvoices, + updateInvoiceStatus, + type Invoice, +} from "@/lib/invoices"; + +const BASE_FORM = { + recipient: "GABCDEF123", + clientName: "Acme Corp", + clientEmail: "billing@acme.test", + description: "Consulting", + amount: "250.5", + asset: "USDC", + dueDate: "2026-09-01", +}; + +describe("invoice persistence", () => { + beforeEach(() => { + localStorage.clear(); + }); + + it("loadInvoices returns [] when nothing stored / on corrupt data", () => { + expect(loadInvoices()).toEqual([]); + localStorage.setItem(INVOICES_STORAGE_KEY, "{not-json"); + expect(loadInvoices()).toEqual([]); + localStorage.setItem(INVOICES_STORAGE_KEY, JSON.stringify({ nope: true })); + expect(loadInvoices()).toEqual([]); + }); + + it("createInvoice persists and assigns number + draft status", async () => { + const inv = await createInvoice(BASE_FORM); + expect(inv.status).toBe("draft"); + expect(inv.invoiceNumber).toBe("INV-00001"); + expect(inv.clientName).toBe("Acme Corp"); + expect(inv.amount).toBe("250.5"); + expect(inv.asset).toBe("USDC"); + expect(loadInvoices()).toHaveLength(1); + }); + + it("createInvoice increments invoice numbers and normalizes asset code", async () => { + await createInvoice(BASE_FORM); + const second = await createInvoice({ ...BASE_FORM, amount: "10", asset: " xlm " }); + expect(second.invoiceNumber).toBe("INV-00002"); + expect(second.asset).toBe("XLM"); + }); + + it("createInvoice tolerates missing optional fields", async () => { + const inv = await createInvoice({ recipient: "GX", amount: "5" }); + expect(inv.clientName).toBe("GX"); + expect(inv.description).toBe("Invoice"); + expect(inv.asset).toBe("XLM"); + expect(inv.dueDate).toBeTruthy(); + expect(String(inv.createdAt)).toBeTruthy(); + }); + + it("updateInvoiceStatus persists the new status", async () => { + const inv = await createInvoice(BASE_FORM); + expect(updateInvoiceStatus(inv.id, "sent")).toBe(true); + expect(loadInvoices()[0].status).toBe("sent"); + expect(updateInvoiceStatus("missing-id", "paid")).toBe(false); + }); + + it("deleteInvoice removes the record", async () => { + const a = await createInvoice(BASE_FORM); + const b = await createInvoice({ ...BASE_FORM, clientName: "Other" }); + expect(deleteInvoice(a.id)).toBe(true); + const left = loadInvoices(); + expect(left).toHaveLength(1); + expect(left[0].id).toBe(b.id); + expect(deleteInvoice(a.id)).toBe(false); + }); + + it("buildInvoiceNumber scans existing numbers", () => { + expect(buildInvoiceNumber([])).toBe("INV-00001"); + const items: Invoice[] = [ + { + id: "1", + invoiceNumber: "INV-00007", + status: "draft", + clientName: "A", + clientEmail: "", + description: "", + amount: "1", + asset: "XLM", + createdAt: "", + dueDate: "", + }, + { + id: "2", + invoiceNumber: "INV-00003", + status: "paid", + clientName: "B", + clientEmail: "", + description: "", + amount: "2", + asset: "XLM", + createdAt: "", + dueDate: "", + }, + ]; + expect(buildInvoiceNumber(items)).toBe("INV-00008"); + }); +}); + +describe("summarizeInvoices", () => { + const mk = (over: Partial): Invoice => ({ + id: over.id ?? "x", + invoiceNumber: "INV-00001", + status: (over.status as Invoice["status"]) ?? "draft", + clientName: "A", + clientEmail: "", + description: "", + amount: over.amount ?? "0", + asset: "XLM", + createdAt: "", + dueDate: "", + }); + + it("aggregates totals per status and outstanding amounts", () => { + const invoices = [ + mk({ id: "1", status: "draft", amount: "100" }), + mk({ id: "2", status: "sent", amount: "200" }), + mk({ id: "3", status: "paid", amount: "300" }), + mk({ id: "4", status: "overdue", amount: "50.5" }), + ]; + const s = summarizeInvoices(invoices); + expect(s.total).toBeCloseTo(650.5); + expect(s.byStatus.draft).toBeCloseTo(100); + expect(s.byStatus.sent).toBeCloseTo(200); + expect(s.byStatus.paid).toBeCloseTo(300); + expect(s.byStatus.overdue).toBeCloseTo(50.5); + // outstanding = sent + overdue (unpaid issued amounts) + expect(s.outstanding).toBeCloseTo(250.5); + }); + + it("is resilient to non-numeric amounts", () => { + const s = summarizeInvoices([mk({ status: "paid", amount: "abc" })]); + expect(s.total).toBe(0); + expect(s.byStatus.paid).toBe(0); + }); +}); + +describe("sendInvoiceReminder", () => { + beforeEach(() => { + localStorage.clear(); + }); + + it("records lastReminderAt on the invoice", async () => { + const inv = await createInvoice(BASE_FORM); + expect(sendInvoiceReminder(inv)).toBe(true); + const stored = loadInvoices(); + expect(stored[0].lastReminderAt).toBeTruthy(); + }); + + it("falls back to a generic mailto when no client email", async () => { + const inv = await createInvoice({ recipient: "GX", amount: "1" }); + expect(sendInvoiceReminder(inv)).toBe(true); + }); +}); \ No newline at end of file diff --git a/frontend/components/InvoiceCard.tsx b/frontend/components/InvoiceCard.tsx index ce463451..46643247 100644 --- a/frontend/components/InvoiceCard.tsx +++ b/frontend/components/InvoiceCard.tsx @@ -6,6 +6,7 @@ interface InvoiceCardProps { invoice: Invoice; onView: (id: string) => void; onDownload: (id: string) => void; + onReminder?: (invoice: Invoice) => void; } const statusStyles: Record = { @@ -15,7 +16,12 @@ const statusStyles: Record = { overdue: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400", }; -export default function InvoiceCard({ invoice, onView, onDownload }: InvoiceCardProps) { +export default function InvoiceCard({ + invoice, + onView, + onDownload, + onReminder, +}: InvoiceCardProps) { const isOverdue = invoice.status === "overdue" || (invoice.status === "sent" && new Date(invoice.dueDate) < new Date()); @@ -72,6 +78,23 @@ export default function InvoiceCard({ invoice, onView, onDownload }: InvoiceCard > View Details + {onReminder !== undefined && ( + + )} + + + {/* Summary cards */} +
+ {statCards.map((card) => ( +
+

+ {card.label} +

+

{card.value}

+
+ ))} +
+ + {/* Filter tabs */} +
+ {FILTERS.map((f) => ( + + ))} +
+ + {/* List */} + {!loaded ? ( +

{t("invoices.loading")}

+ ) : visible.length === 0 ? ( +
+ + + +

+ {filter === "all" + ? t("invoices.empty") + : t("invoices.emptyFilter")} +

+ {filter === "all" && ( + + )} +
+ ) : ( +
+ {visible.map((inv) => ( + + ))} +
+ )} + + + {/* Create modal */} + setShowCreate(false)} + onCreated={() => { + setShowCreate(false); + refresh(); + addToast(t("invoices.created"), "success"); + }} + /> + + {/* Detail overlay (deep-linked) */} + + {selected && ( + +
e.stopPropagation()} className="w-full max-w-2xl"> + +
+
+ )} +
); } diff --git a/frontend/public/locales/ar/common.json b/frontend/public/locales/ar/common.json index 09ef7d49..0d79df83 100644 --- a/frontend/public/locales/ar/common.json +++ b/frontend/public/locales/ar/common.json @@ -154,7 +154,8 @@ "switchToDark": "التبديل إلى الوضع الداكن", "switchToLight": "التبديل إلى الوضع الفاتح", "trade": "تداول", - "transactions": "المعاملات" + "transactions": "المعاملات", + "invoices": "الفواتير" }, "portfolio": { "addToken": "إضافة رمز", @@ -332,5 +333,26 @@ "title": "المدفوعات الأخيرة", "tryAgain": "حاول مرة أخرى", "viewOnExpert": "عرض على Stellar Expert" + }, + "invoices": { + "title": "الفواتير", + "subtitle": "تتبع الفواتير المستحقة والمدفوعة، قم بالتصفية حسب الحالة، وأرسل التذكيرات.", + "createNew": "فاتورة جديدة", + "createFirst": "أنشئ فاتورتك الأولى", + "loading": "جارٍ تحميل الفواتير…", + "empty": "لا توجد فواتير بعد. أنشئ واحدة للبدء.", + "emptyFilter": "لا توجد فواتير تطابق هذا الفلتر.", + "filters": "تصفية حسب الحالة", + "filterAll": "الكل", + "filterDraft": "مسودة", + "filterSent": "مرسلة", + "filterPaid": "مدفوعة", + "filterOverdue": "متأخرة", + "statOutstanding": "مستحق", + "statPaid": "مدفوع", + "statTotal": "الإجمالي", + "created": "تم إنشاء الفاتورة", + "reminderSent": "تم إرسال التذكير", + "downloadFailed": "فشل تنزيل ملف PDF" } } diff --git a/frontend/public/locales/en/common.json b/frontend/public/locales/en/common.json index 858f470f..8adb9a3e 100644 --- a/frontend/public/locales/en/common.json +++ b/frontend/public/locales/en/common.json @@ -154,7 +154,8 @@ "switchToDark": "Switch to dark mode", "switchToLight": "Switch to light mode", "trade": "Trade", - "transactions": "Transactions" + "transactions": "Transactions", + "invoices": "Invoices" }, "portfolio": { "addToken": "Add Token", @@ -332,5 +333,26 @@ "title": "Recent Payments", "tryAgain": "Try again", "viewOnExpert": "View on Stellar Expert" + }, + "invoices": { + "title": "Invoices", + "subtitle": "Track outstanding and paid invoices, filter by status, and send reminders.", + "createNew": "New Invoice", + "createFirst": "Create your first invoice", + "loading": "Loading invoices…", + "empty": "No invoices yet. Create one to get started.", + "emptyFilter": "No invoices match this filter.", + "filters": "Filter by status", + "filterAll": "All", + "filterDraft": "Draft", + "filterSent": "Sent", + "filterPaid": "Paid", + "filterOverdue": "Overdue", + "statOutstanding": "Outstanding", + "statPaid": "Paid", + "statTotal": "Total", + "created": "Invoice created", + "reminderSent": "Reminder sent", + "downloadFailed": "Failed to download PDF" } } diff --git a/frontend/public/locales/es/common.json b/frontend/public/locales/es/common.json index a0881940..6d23b70c 100644 --- a/frontend/public/locales/es/common.json +++ b/frontend/public/locales/es/common.json @@ -154,7 +154,8 @@ "switchToDark": "Cambiar a modo oscuro", "switchToLight": "Cambiar a modo claro", "trade": "Comercio", - "transactions": "Transacciones" + "transactions": "Transacciones", + "invoices": "Facturas" }, "portfolio": { "addToken": "Agregar token", @@ -332,5 +333,26 @@ "title": "Pagos Recientes", "tryAgain": "Intentar de nuevo", "viewOnExpert": "Ver en Stellar Expert" + }, + "invoices": { + "title": "Facturas", + "subtitle": "Consulta facturas pendientes y pagadas, filtra por estado y envía recordatorios.", + "createNew": "Nueva factura", + "createFirst": "Crea tu primera factura", + "loading": "Cargando facturas…", + "empty": "Aún no hay facturas. Crea una para empezar.", + "emptyFilter": "Ninguna factura coincide con este filtro.", + "filters": "Filtrar por estado", + "filterAll": "Todas", + "filterDraft": "Borrador", + "filterSent": "Enviada", + "filterPaid": "Pagada", + "filterOverdue": "Vencida", + "statOutstanding": "Pendiente", + "statPaid": "Pagado", + "statTotal": "Total", + "created": "Factura creada", + "reminderSent": "Recordatorio enviado", + "downloadFailed": "No se pudo descargar el PDF" } } diff --git a/frontend/public/locales/fr/common.json b/frontend/public/locales/fr/common.json index 6f84c785..641dc708 100644 --- a/frontend/public/locales/fr/common.json +++ b/frontend/public/locales/fr/common.json @@ -154,7 +154,8 @@ "switchToDark": "Passer au mode sombre", "switchToLight": "Passer au mode clair", "trade": "Échange", - "transactions": "Transactions" + "transactions": "Transactions", + "invoices": "Factures" }, "portfolio": { "addToken": "Ajouter un jeton", @@ -332,5 +333,26 @@ "title": "Paiements Récents", "tryAgain": "Réessayer", "viewOnExpert": "Voir sur Stellar Expert" + }, + "invoices": { + "title": "Factures", + "subtitle": "Suivez les factures impayées et payées, filtrez par statut et envoyez des rappels.", + "createNew": "Nouvelle facture", + "createFirst": "Créez votre première facture", + "loading": "Chargement des factures…", + "empty": "Aucune facture pour le moment. Créez-en une pour commencer.", + "emptyFilter": "Aucune facture ne correspond à ce filtre.", + "filters": "Filtrer par statut", + "filterAll": "Toutes", + "filterDraft": "Brouillon", + "filterSent": "Envoyée", + "filterPaid": "Payée", + "filterOverdue": "En retard", + "statOutstanding": "Impayé", + "statPaid": "Payé", + "statTotal": "Total", + "created": "Facture créée", + "reminderSent": "Rappel envoyé", + "downloadFailed": "Échec du téléchargement du PDF" } } diff --git a/frontend/public/locales/he/common.json b/frontend/public/locales/he/common.json index f9dd275d..ca8bce37 100644 --- a/frontend/public/locales/he/common.json +++ b/frontend/public/locales/he/common.json @@ -154,7 +154,8 @@ "switchToDark": "מעבר למצב כהה", "switchToLight": "מעבר למצב בהיר", "trade": "מסחר", - "transactions": "עסקאות" + "transactions": "עסקאות", + "invoices": "חשבוניות" }, "portfolio": { "addToken": "הוספת טוקן", @@ -332,5 +333,26 @@ "title": "תשלומים אחרונים", "tryAgain": "נסה שוב", "viewOnExpert": "צפייה ב-Stellar Expert" + }, + "invoices": { + "title": "חשבוניות", + "subtitle": "עקוב אחר חשבוניות שלא שולמו ושולמו, סנן לפי סטטוס ושלח תזכורות.", + "createNew": "חשבונית חדשה", + "createFirst": "צור חשבונית ראשונה", + "loading": "טוען חשבוניות…", + "empty": "אין עדיין חשבוניות. צור אחת כדי להתחיל.", + "emptyFilter": "אין חשבוניות תואמות לפילטר זה.", + "filters": "סנן לפי סטטוס", + "filterAll": "הכל", + "filterDraft": "טיוטה", + "filterSent": "נשלחה", + "filterPaid": "שולמה", + "filterOverdue": "באיחור", + "statOutstanding": "יתרה", + "statPaid": "שולם", + "statTotal": "סה״כ", + "created": "החשבונית נוצרה", + "reminderSent": "התזכורת נשלחה", + "downloadFailed": "הורדת ה-PDF נכשלה" } } diff --git a/frontend/public/locales/ja/common.json b/frontend/public/locales/ja/common.json index d0cdf750..dc9607e0 100644 --- a/frontend/public/locales/ja/common.json +++ b/frontend/public/locales/ja/common.json @@ -21,7 +21,8 @@ "removeLastAccountWarning": "これが唯一のアカウントです。削除するとウォレットが切断されます。もう一度クリックして確認してください。", "switchAccount": "アカウント切替", "switchAccountShortcut": "Ctrl+K(Cmd+K)でアカウント切替", - "primary": "プライマリ" + "primary": "プライマリ", + "invoices": "請求書" }, "home": { "badge": "Stellar Testnet 構築 · オープンソース", @@ -305,5 +306,26 @@ "languageTitle": "言語", "languageDescription": "アプリケーションインターフェースの言語を選択してください。", "rtlSupportNote": "RTL サポート:アラビア語とヘブライ語のレイアウトはミラー表示されます。" + }, + "invoices": { + "title": "請求書", + "subtitle": "未払い・支払済みの請求書を確認し、ステータスで絞り込んでリマインダーを送信できます。", + "createNew": "新規請求書", + "createFirst": "最初の請求書を作成する", + "loading": "請求書を読み込み中…", + "empty": "請求書はまだありません。作成して始めましょう。", + "emptyFilter": "このフィルターに一致する請求書はありません。", + "filters": "ステータスで絞り込む", + "filterAll": "すべて", + "filterDraft": "下書き", + "filterSent": "送信済み", + "filterPaid": "支払済み", + "filterOverdue": "期限切れ", + "statOutstanding": "未払い", + "statPaid": "支払済み", + "statTotal": "合計", + "created": "請求書を作成しました", + "reminderSent": "リマインダーを送信しました", + "downloadFailed": "PDFのダウンロードに失敗しました" } } diff --git a/frontend/public/locales/pt/common.json b/frontend/public/locales/pt/common.json index 52d2e2b7..5fd1fab0 100644 --- a/frontend/public/locales/pt/common.json +++ b/frontend/public/locales/pt/common.json @@ -21,7 +21,8 @@ "removeLastAccountWarning": "Esta é sua única conta. Removê-la desconecta sua carteira. Clique novamente para confirmar.", "switchAccount": "Trocar conta", "switchAccountShortcut": "Pressione Ctrl+K (Cmd+K) para trocar de conta", - "primary": "Principal" + "primary": "Principal", + "invoices": "Faturas" }, "home": { "badge": "Construído em Stellar Testnet · Código Aberto", @@ -305,5 +306,26 @@ "languageTitle": "Idioma", "languageDescription": "Selecione seu idioma preferido para a interface do aplicativo.", "rtlSupportNote": "Suporte RTL: layouts em árabe são espelhados." + }, + "invoices": { + "title": "Faturas", + "subtitle": "Acompanhe faturas pendentes e pagas, filtre por status e envie lembretes.", + "createNew": "Nova fatura", + "createFirst": "Crie sua primeira fatura", + "loading": "Carregando faturas…", + "empty": "Nenhuma fatura ainda. Crie uma para começar.", + "emptyFilter": "Nenhuma fatura corresponde a este filtro.", + "filters": "Filtrar por status", + "filterAll": "Todas", + "filterDraft": "Rascunho", + "filterSent": "Enviada", + "filterPaid": "Paga", + "filterOverdue": "Vencida", + "statOutstanding": "Pendente", + "statPaid": "Pago", + "statTotal": "Total", + "created": "Fatura criada", + "reminderSent": "Lembrete enviado", + "downloadFailed": "Falha ao baixar o PDF" } } diff --git a/frontend/stories/InvoicesPage.stories.tsx b/frontend/stories/InvoicesPage.stories.tsx new file mode 100644 index 00000000..c9deddce --- /dev/null +++ b/frontend/stories/InvoicesPage.stories.tsx @@ -0,0 +1,94 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { INVOICES_STORAGE_KEY, type Invoice } from "@/lib/invoices"; +import InvoicesPage from "../pages/invoices"; + +const invoices: Invoice[] = [ + { + id: "inv-001", + invoiceNumber: "INV-00017", + status: "sent", + clientName: "Stellar Voyage Ltd", + clientEmail: "billing@stellar-voyage.test", + description: "Q3 integration sprint", + amount: "250", + asset: "USDC", + createdAt: "2026-08-01T09:00:00.000Z", + dueDate: "2026-09-01", + transactionHash: "abc123", + }, + { + id: "inv-002", + invoiceNumber: "INV-00018", + status: "draft", + clientName: "MergeOS Foundation", + clientEmail: "ops@mergeos.test", + description: "Design system consulting", + amount: "120", + asset: "XLM", + createdAt: "2026-08-12T14:30:00.000Z", + dueDate: "2026-09-15", + }, + { + id: "inv-003", + invoiceNumber: "INV-00019", + status: "paid", + clientName: "Aqua Protocol", + clientEmail: "pay@aqua.test", + description: "M1 delivery", + amount: "500", + asset: "USDC", + createdAt: "2026-07-20T08:00:00.000Z", + dueDate: "2026-08-05", + }, + { + id: "inv-004", + invoiceNumber: "INV-00020", + status: "overdue", + clientName: "SoroMint Studios", + clientEmail: "finance@soromint.test", + description: "Asset illustration pack", + amount: "75.5", + asset: "XLM", + createdAt: "2026-06-15T10:00:00.000Z", + dueDate: "2026-07-01", + }, +]; + +const meta = { + title: "Pages/Invoices", + component: InvoicesPage, + tags: ["autodocs"], + parameters: { + layout: "fullscreen", + docs: { + description: { + component: + "Invoice dashboard: list invoices by status, filter with tabs, view per-status totals, send payment reminders, and deep-link into the detail panel via ?invoice=.", + }, + }, + }, + play: async ({}) => { + if (window) { + window.localStorage.clear(); + window.localStorage.setItem(INVOICES_STORAGE_KEY, JSON.stringify(invoices)); + } + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const Empty: Story = { + play: async () => { + window.localStorage.clear(); + }, +}; + +export const OverdueFilter: Story = { + play: async () => { + window.localStorage.clear(); + window.localStorage.setItem(INVOICES_STORAGE_KEY, JSON.stringify(invoices)); + }, +}; \ No newline at end of file