Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions frontend-typescript/src/api/budgetApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Blob> => {
const { data } = await gatewayApi.get(`/budgets/${budgetId}/export.xlsx`, {
responseType: "blob",
});
return data;
};
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ vi.mock("@/api/budgetApi", async (importOriginal) => {
...actual,
editBudget: vi.fn(),
saveBudgetAsTemplate: vi.fn(),
exportBudgetWorkbook: vi.fn(),
};
});

Expand Down Expand Up @@ -51,6 +52,9 @@ const editBudgetMock = budgetApi.editBudget as unknown as ReturnType<typeof vi.f
const saveBudgetAsTemplateMock = budgetApi.saveBudgetAsTemplate as unknown as ReturnType<
typeof vi.fn
>;
const exportBudgetWorkbookMock = budgetApi.exportBudgetWorkbook as unknown as ReturnType<
typeof vi.fn
>;
const getCurrentCustomerIdMock = roleAccess.getCurrentCustomerId as unknown as ReturnType<
typeof vi.fn
>;
Expand Down Expand Up @@ -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(<BudgetViewHeader budget={makeBudget()} isLocked={false} />);

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(<BudgetViewHeader budget={makeBudget()} isLocked={false} />);

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(<BudgetViewHeader budget={makeBudget()} isLocked={false} />);
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(<BudgetViewHeader budget={makeBudget()} isLocked={false} />);
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();
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -231,6 +235,7 @@ export function BudgetViewHeader({
)}
</div>
<div className="flex items-center gap-2">
<BudgetActionsToolbar budget={budget} />
{isEditMode ? (
<>
<Button
Expand Down Expand Up @@ -518,6 +523,64 @@ export function BudgetViewHeader({
);
}

// Icon-button cluster for lightweight actions — export today, room for more later.
function BudgetActionsToolbar({ budget }: { budget: Budget }) {
const canExport =
isBudgetOwner(budget, getCurrentCustomerId()) ||
isBudgetFunder(budget, getCurrentCustomerId());

if (!canExport) return null;

return (
<div className="flex items-center gap-0.5 bg-slate-50 border border-slate-200 rounded-lg p-0.5">
<ExportBudgetAction budget={budget} />
</div>
);
}

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 (
<div className="relative">
<Button
variant="icon"
title="Export to Excel"
onClick={handleExport}
disabled={isExporting}
>
{isExporting ? <Loader2 size={18} className="animate-spin" /> : <Download size={18} />}
</Button>
{error && (
<p className="absolute right-0 top-full mt-1 w-48 text-xs text-red-600 bg-white border border-red-200 rounded-md shadow-md p-2 z-10">
{error}
</p>
)}
</div>
);
}

function BudgetConfirmAction({
budget,
onConfirmed,
Expand Down
6 changes: 3 additions & 3 deletions openspec/changes/budget-feat-313-excel-export/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading