diff --git a/frontend-typescript/src/api/budgetApi.ts b/frontend-typescript/src/api/budgetApi.ts index 55fbb3c..e9e9932 100644 --- a/frontend-typescript/src/api/budgetApi.ts +++ b/frontend-typescript/src/api/budgetApi.ts @@ -63,3 +63,11 @@ export const saveBudgetAsTemplate = async ( }); return data; }; + +// Streams the workbook bytes directly, unlike downloadAttachment's presigned-URL redirect. +export const exportBudgetWorkbook = async (budgetId: string): Promise => { + const { data } = await gatewayApi.get(`/budgets/${budgetId}/export.xlsx`, { + responseType: "blob", + }); + return data; +}; diff --git a/frontend-typescript/src/pages/Budgets/components/BudgetViewHeader.test.tsx b/frontend-typescript/src/pages/Budgets/components/BudgetViewHeader.test.tsx index ee89014..f5cddb1 100644 --- a/frontend-typescript/src/pages/Budgets/components/BudgetViewHeader.test.tsx +++ b/frontend-typescript/src/pages/Budgets/components/BudgetViewHeader.test.tsx @@ -15,6 +15,7 @@ vi.mock("@/api/budgetApi", async (importOriginal) => { ...actual, editBudget: vi.fn(), saveBudgetAsTemplate: vi.fn(), + exportBudgetWorkbook: vi.fn(), }; }); @@ -51,6 +52,9 @@ const editBudgetMock = budgetApi.editBudget as unknown as ReturnType; +const exportBudgetWorkbookMock = budgetApi.exportBudgetWorkbook as unknown as ReturnType< + typeof vi.fn +>; const getCurrentCustomerIdMock = roleAccess.getCurrentCustomerId as unknown as ReturnType< typeof vi.fn >; @@ -752,6 +756,61 @@ describe("BudgetViewHeader metadata edit", () => { }); }); +describe("BudgetViewHeader export action", () => { + beforeEach(() => { + vi.clearAllMocks(); + getCurrentCustomerIdMock.mockReturnValue("owner-1"); + isBudgetOwnerMock.mockReturnValue(true); + isBudgetFunderMock.mockReturnValue(false); + window.URL.createObjectURL = vi.fn(() => "blob:mock-url"); + window.URL.revokeObjectURL = vi.fn(); + }); + + it("hides the export action for a non-owner, non-funder viewer", () => { + isBudgetOwnerMock.mockReturnValue(false); + isBudgetFunderMock.mockReturnValue(false); + + renderHeader(); + + expect(screen.queryByTitle("Export to Excel")).not.toBeInTheDocument(); + }); + + it("shows the export action to the matching funder even when they are not the owner", () => { + isBudgetOwnerMock.mockReturnValue(false); + isBudgetFunderMock.mockReturnValue(true); + + renderHeader(); + + expect(screen.getByTitle("Export to Excel")).toBeInTheDocument(); + }); + + it("downloads the workbook using the budget's name as the filename", async () => { + const user = userEvent.setup(); + const blob = new Blob(["xlsx-bytes"]); + exportBudgetWorkbookMock.mockResolvedValue(blob); + + renderHeader(); + await user.click(screen.getByTitle("Export to Excel")); + + await waitFor(() => expect(exportBudgetWorkbookMock).toHaveBeenCalledWith("b1")); + expect(window.URL.createObjectURL).toHaveBeenCalledWith(blob); + expect(window.URL.revokeObjectURL).toHaveBeenCalledWith("blob:mock-url"); + }); + + it("shows an inline error and stays on the page when the export request fails", async () => { + const user = userEvent.setup(); + exportBudgetWorkbookMock.mockRejectedValue(new Error("failed")); + + renderHeader(); + await user.click(screen.getByTitle("Export to Excel")); + + await waitFor(() => + expect(screen.getByText(/failed to export budget/i)).toBeInTheDocument(), + ); + expect(screen.getByTitle("Export to Excel")).toBeInTheDocument(); + }); +}); + describe("BudgetViewHeader funder picker", () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/frontend-typescript/src/pages/Budgets/components/BudgetViewHeader.tsx b/frontend-typescript/src/pages/Budgets/components/BudgetViewHeader.tsx index 4f50488..9c77d49 100644 --- a/frontend-typescript/src/pages/Budgets/components/BudgetViewHeader.tsx +++ b/frontend-typescript/src/pages/Budgets/components/BudgetViewHeader.tsx @@ -1,9 +1,13 @@ import { useEffect, useState } from "react"; -import { Archive } from "lucide-react"; +import { Archive, Download, Loader2 } from "lucide-react"; import { Card, CardContent, CardHeader } from "@/components/ui/Card"; import Button, { ConfirmDeleteButton } from "@/components/ui/Button"; import Input from "@/components/ui/Input"; -import { editBudget, saveBudgetAsTemplate } from "@/api/budgetApi"; +import { + editBudget, + exportBudgetWorkbook, + saveBudgetAsTemplate, +} from "@/api/budgetApi"; import { useFunderPicker } from "@/hooks/useFunderPicker"; import { getCurrentCustomerId, @@ -231,6 +235,7 @@ export function BudgetViewHeader({ )}
+ {isEditMode ? ( <>
+ ); +} + +function ExportBudgetAction({ budget }: { budget: Budget }) { + const [isExporting, setIsExporting] = useState(false); + const [error, setError] = useState(""); + + const handleExport = async () => { + setIsExporting(true); + setError(""); + try { + const blob = await exportBudgetWorkbook(budget.id); + const url = window.URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = `${budget.name}.xlsx`; + document.body.appendChild(link); + link.click(); + link.remove(); + window.URL.revokeObjectURL(url); + } catch { + setError("Failed to export budget. Please try again."); + } finally { + setIsExporting(false); + } + }; + + return ( +
+ + {error && ( +

+ {error} +

+ )} +
+ ); +} + function BudgetConfirmAction({ budget, onConfirmed, diff --git a/openspec/changes/budget-feat-313-excel-export/tasks.md b/openspec/changes/budget-feat-313-excel-export/tasks.md index 6296819..9eee1a3 100644 --- a/openspec/changes/budget-feat-313-excel-export/tasks.md +++ b/openspec/changes/budget-feat-313-excel-export/tasks.md @@ -41,13 +41,13 @@ _Generated by `scripts/flow.py sync budget-feat-313-excel-export` — do not edi - [x] 3.1 Extend `excel_export_crud.py` with a query returning every report line across all of the budget's reports, each with its ordered list of allocations (or none) - [x] 3.2 Implement Sheet 3 as an `ExpenseListSheet(_SheetWriter)` subclass (see design.md Decision 12): one row per report line when zero or one allocation exists, one row per allocation (subline) when a report line has more than one, each subline carrying its own conversion date/rate/converted amount; verify with a unit test covering an unallocated expense, a single-lot expense, and a multi-lot expense (asserting the multi-lot rows sum to the expense's full amount) — **extended** (design.md Decision 14, after real-data testing): added a Category column; an unallocated remainder now uses `estimated_exchange_rate` (flagged) instead of being left blank when the budget has one; Sheet 2's expense columns now `SUMIF` this sheet via a hidden budget-line-id column instead of a separate Python rollup -- [ ] 3.3 Run backend lint/tests clean for `services/budget`; PR merged (`Closes` this group's sub-issue) +- [x] 3.3 Run backend lint/tests clean for `services/budget`; PR merged (`Closes` this group's sub-issue) ## 4. Frontend export button — depends on 1, 2, 3 — Issue #322 -- [ ] 4.1 Add an "Export to Excel" button to the single-budget detail view in `frontend-typescript`, visible whenever the viewer has read access to the budget (owner or funder), following existing button/permission conventions on that view — a plain button at this stage; group 7 turns it into a template picker +- [x] 4.1 Add an "Export to Excel" button to the single-budget detail view in `frontend-typescript`, visible whenever the viewer has read access to the budget (owner or funder), following existing button/permission conventions on that view — a plain button at this stage; group 7 turns it into a template picker - [ ] 4.2 Wire the button to `GET /budgets/{budget_id}/export.xlsx` and trigger a browser download of the response with a filename derived from the budget's name; verify manually that a downloaded file opens in Excel/LibreOffice with all 3 sheets populated -- [ ] 4.3 Add inline error handling that shows a message without navigating away when the request fails; verify with a frontend test that simulates a failed request and asserts the user stays on the budget detail view with an error shown +- [x] 4.3 Add inline error handling that shows a message without navigating away when the request fails; verify with a frontend test that simulates a failed request and asserts the user stays on the budget detail view with an error shown - [ ] 4.4 Run frontend lint/tests clean; manually verify end-to-end against a real budget with lines, receipts, multi-lot conversions, and report expenses (owner and funder logins); PR merged (`Closes` this group's sub-issue) ## 5. Export templates: model, ownership, and candidate resolution — depends on 1 — Issue #323