Skip to content

Commit 48d50fc

Browse files
ilramdhanclaude
andauthored
feat(finance,frontend): add product cost-sheet export UI, fill parameter drawer, and UI fixes (mutugading#74)
* fix(finance): remove unused CalculateRMCost generated types Follows the backend/proto removal of the synchronous, admin-only CalculateRMCost RPC (V1 consumption-only calc, superseded by V2 and the async TriggerRMCostCalculation RPC). No frontend service/hook/BFF route ever invoked this RPC — dead code. Removes CalculateRMCostRequest/CalculateRMCostResponse from the generated proto types and from the hand-written re-export wrapper. Authored-By: Ilham R <ilhamram332@gmail.com> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(frontend): resolve fill-config UUID display + responsiveness bugs Global Defaults and Product Overrides tabs on the fill-config page rendered raw actor UUIDs instead of user/dept names. Add FillConfigActorLabel to resolve unprefixed USER/DEPT actor values via UserName/DeptName, and fix fixed-width popovers/grids on FillConfigForm and the fill-tasks request picker so they don't overflow on small screens. * feat(frontend): replace fill parameters page with an inline drawer Remove the standalone fill/[taskId] route and FillParamEntryPage in favor of FillParamDrawer opened directly from FillTaskRow, matching the drawer pattern used elsewhere in the fill-assignment flow. Drop the Card wrapper from FillParamProductSection to match the Edit Params drawer used on the product-request detail page, convert FillTrackingTable to shadcn Table components, and add a breadcrumb override on the request detail page to avoid flashing the raw request ID. * feat(finance): regenerate TS types for product cost-sheet export contracts Generated from goapps-shared-proto's finance/v1/cost_calc.proto after adding the export/batch/route-cost-sheet/period RPCs and messages. Authored-By: Ilham R <ilhamram332@gmail.com> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(finance): add product cost-sheet export UI, BFF routes, and hooks Adds the BFF proxy routes for the async export pipeline (request-export, periods, and the exports/[jobId] status/download/download-all/children tree, including per-child download-url) and the corresponding TanStack Query hooks in use-cost-calc.ts (useRequestCostSheetExport, useExportJobStatus, useExportBatchProgress, recent-exports list). ExportCostSheetButton drives both standalone and batch-parent export jobs from one control: shows live progress for batch children, renders a Download action once a standalone job completes, and exposes a dismiss control to clear a previously-selected job back to idle. ExportBatchFilesPopover lists per-child download links (each presigned fresh on click) plus a download-all zip action. RecentExportsPopover lets a user reopen a past export by job id without re-triggering a new one. cost-results-page-client.tsx replaces the standalone cost-result-filters component with an inline filter bar wired to the new product_type_ids/ sort_by/sort_order/period params, and threads exportJobId through URL state so a selected export survives a refresh. data-table.tsx gains hideColumnsButton (the export toolbar owns its own actions) and switches skeleton loading to per-row TableRow cells so column widths don't jump once data arrives. notification-actions.ts generalizes the notification-click download-route map to also cover finance.product_cost_sheet_export. Authored-By: Ilham R <ilhamram332@gmail.com> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(frontend): breadcrumb never falls back to raw sys-id on cost result detail The cost-result detail page previously let dynamic-breadcrumb derive the trail from the URL segment, which showed the raw system id when the product/period label hadn't loaded yet (and could flash the id before the real label painted in). Adds useBreadcrumbTrail so the page can push an explicit, already-resolved trail (using the new calcTypeLabel helper in format.ts, also shared by the results list), and switches dynamic-breadcrumb's override wiring to useIsomorphicLayoutEffect so the trail is set before first paint instead of flashing the fallback first. Authored-By: Ilham R <ilhamram332@gmail.com> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(finance): resolve MASTER_LOOKUP params in formula input picker Formula inputs such as F_YARN_VB1_LOSS consume MASTER_LOOKUP params (CHANGE_OVER_QLTY_LOSS, VOLUME_BUCKET_1_QTY), but the input picker only queried INPUT/RATE/CALCULATED, so those ids were unresolvable and rendered as raw UUIDs with no way to checklist them. Adds the MASTER_LOOKUP param query to the pool and a resolveParamLabel fallback that checks the pool first, then the formula's own joined inputParams, before ever falling back to a placeholder — never a bare UUID. Authored-By: Ilham R <ilhamram332@gmail.com> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(frontend): clarify use-url-state ref staleness in the setState comment The comment claimed the ref was synced during render, but it is synced in a passive effect and lags by one commit. No functional change — the lag is not currently observable since every caller passes a whole new object or derives from state directly — just correcting the comment so it doesn't mislead the next person relying on the ref inside an earlier-running effect. Authored-By: Ilham R <ilhamram332@gmail.com> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2 parents 902277d + 3df8121 commit 48d50fc

42 files changed

Lines changed: 5889 additions & 1435 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

src/app/(dashboard)/finance/costing/fill-config/page.tsx

Lines changed: 194 additions & 131 deletions
Large diffs are not rendered by default.

src/app/(dashboard)/finance/costing/fill-tasks/page.tsx

Lines changed: 57 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,16 @@ import { useUser } from "@/providers/auth-provider";
1212
import { FillTrackingTable } from "@/components/finance/fill-assignment/FillTrackingTable";
1313
import { FillBlockerAlert } from "@/components/finance/fill-assignment/FillBlockerAlert";
1414
import { Button } from "@/components/ui/button";
15+
import {
16+
Card,
17+
CardContent,
18+
CardDescription,
19+
CardHeader,
20+
CardTitle,
21+
} from "@/components/ui/card";
22+
import { Skeleton } from "@/components/ui/skeleton";
23+
import { PageHeader } from "@/components/common/page-header";
24+
import { EmptyState } from "@/components/common/empty-state";
1525
import {
1626
Command,
1727
CommandEmpty,
@@ -56,15 +66,17 @@ function RequestPicker({ selectedId, onSelect }: RequestPickerProps) {
5666
variant="outline"
5767
role="combobox"
5868
aria-expanded={open}
59-
className="w-full max-w-md justify-between"
69+
className="w-full justify-between font-normal"
6070
>
61-
{selectedItem
62-
? `${selectedItem.requestNo}${selectedItem.title}`
63-
: "Select request…"}
71+
<span className="truncate">
72+
{selectedItem
73+
? `${selectedItem.requestNo}${selectedItem.title}`
74+
: "Select request…"}
75+
</span>
6476
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
6577
</Button>
6678
</PopoverTrigger>
67-
<PopoverContent className="w-[480px] p-0">
79+
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
6880
<Command shouldFilter={false}>
6981
<CommandInput
7082
placeholder="Search by request no or title…"
@@ -149,23 +161,22 @@ function FillTasksContent({ requestId, onRequestSelect }: FillTasksContentProps)
149161
</div>
150162

151163
{!requestId ? (
152-
<div className="flex flex-col items-center justify-center py-24 text-center gap-4">
153-
<ClipboardList className="h-12 w-12 text-muted-foreground/40" />
154-
<div>
155-
<p className="text-base font-medium text-muted-foreground">
156-
No request selected
157-
</p>
158-
<p className="text-sm text-muted-foreground mt-1">
159-
Search for a product request above to view its fill tasks.
160-
</p>
161-
</div>
162-
</div>
164+
<EmptyState
165+
icon={ClipboardList}
166+
title="No request selected"
167+
description="Search for a product request above to view its fill tasks."
168+
/>
163169
) : isLoading ? (
164-
<p className="text-sm text-muted-foreground">Loading fill tasks…</p>
170+
<div className="space-y-2">
171+
<Skeleton className="h-9 w-full" />
172+
<Skeleton className="h-9 w-full" />
173+
<Skeleton className="h-9 w-full" />
174+
</div>
165175
) : tasks.length === 0 ? (
166-
<p className="text-sm text-muted-foreground">
167-
No fill tasks found for this request.
168-
</p>
176+
<EmptyState
177+
title="No fill tasks found"
178+
description="This request has no fill tasks yet."
179+
/>
169180
) : (
170181
<>
171182
{myBlockerTask && <FillBlockerAlert task={myBlockerTask} />}
@@ -226,20 +237,32 @@ function FillTasksPageContent() {
226237

227238
export default function FillTasksPage() {
228239
return (
229-
<div className="p-6 space-y-6">
230-
<div>
231-
<h1 className="text-2xl font-semibold">Fill Tasks</h1>
232-
<p className="text-sm text-muted-foreground mt-1">
233-
Parameter fill tasks and approvals for costing requests.
234-
</p>
235-
</div>
236-
<Suspense
237-
fallback={
238-
<p className="text-sm text-muted-foreground">Loading…</p>
239-
}
240-
>
241-
<FillTasksPageContent />
242-
</Suspense>
240+
<div className="space-y-6">
241+
<PageHeader
242+
title="Fill Tasks"
243+
subtitle="Parameter fill tasks and approvals for costing requests."
244+
/>
245+
246+
<Card>
247+
<CardHeader>
248+
<CardTitle className="text-sm font-semibold">Fill Tracking</CardTitle>
249+
<CardDescription>
250+
Select a request to view and manage its parameter fill tasks.
251+
</CardDescription>
252+
</CardHeader>
253+
<CardContent className="space-y-4">
254+
<Suspense
255+
fallback={
256+
<div className="space-y-2">
257+
<Skeleton className="h-9 w-full max-w-md" />
258+
<Skeleton className="h-9 w-full" />
259+
</div>
260+
}
261+
>
262+
<FillTasksPageContent />
263+
</Suspense>
264+
</CardContent>
265+
</Card>
243266
</div>
244267
);
245268
}

src/app/(dashboard)/finance/product-requests/[requestId]/detail-client.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { ArrowLeft } from "lucide-react"
66

77
import { PageHeader } from "@/components/common/page-header"
88
import { EmptyState } from "@/components/common/empty-state"
9+
import { useBreadcrumbOverride } from "@/components/common/dynamic-breadcrumb"
910
import { Button } from "@/components/ui/button"
1011
import {
1112
RequestDetailPanel,
@@ -35,6 +36,8 @@ export default function ProductRequestDetailClient({ requestId }: Props) {
3536
fillTasks.every((t) => t.status === "FILL_TASK_STATUS_APPROVED")
3637
const [formOpen, setFormOpen] = useState(false)
3738

39+
useBreadcrumbOverride(request?.requestNo ?? null)
40+
3841
function backToList() {
3942
router.push("/finance/product-requests")
4043
}

src/app/(dashboard)/finance/product-requests/[requestId]/fill/[taskId]/loading.tsx

Lines changed: 0 additions & 10 deletions
This file was deleted.

src/app/(dashboard)/finance/product-requests/[requestId]/fill/[taskId]/page.tsx

Lines changed: 0 additions & 13 deletions
This file was deleted.
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
// GET /api/v1/finance/cost-results/exports/{jobId}/children/{childJobId}/download-url
2+
// — freshly presign one batch child's artifact on demand. The batch-children
3+
// list (see ../../children/route.ts) is fetched once and cached (staleTime:
4+
// Infinity in useExportBatchChildren), so any downloadUrl it carries can be
5+
// stale by the time the user clicks Download (MinIO presigned URLs expire
6+
// after ~5 min). This route re-presigns fresh, every call.
7+
8+
import { NextRequest, NextResponse } from "next/server"
9+
import { getCostCalcClient, createMetadataFromRequest, isGrpcError, handleGrpcError } from "@/lib/grpc"
10+
11+
type Ctx = { params: Promise<{ jobId: string; childJobId: string }> }
12+
13+
export async function GET(request: NextRequest, ctx: Ctx) {
14+
try {
15+
const { jobId, childJobId } = await ctx.params
16+
const metadata = createMetadataFromRequest(request)
17+
const client = getCostCalcClient()
18+
19+
const response = await client.getBatchChildDownloadUrl(
20+
{ parentJobId: jobId, childJobId },
21+
metadata,
22+
)
23+
if (response.base?.isSuccess === false) {
24+
return NextResponse.json(
25+
{ base: response.base },
26+
{ status: Number(response.base?.statusCode ?? "400") || 400 },
27+
)
28+
}
29+
if (!response.downloadUrl) {
30+
return NextResponse.json(
31+
{
32+
base: {
33+
isSuccess: false,
34+
statusCode: "404",
35+
message: "Download URL unavailable",
36+
validationErrors: [],
37+
},
38+
},
39+
{ status: 404 },
40+
)
41+
}
42+
43+
return NextResponse.json({
44+
base: response.base,
45+
data: { downloadUrl: response.downloadUrl, fileName: response.fileName },
46+
})
47+
} catch (error) {
48+
if (isGrpcError(error)) return handleGrpcError(error)
49+
return NextResponse.json(
50+
{
51+
base: {
52+
isSuccess: false,
53+
statusCode: "500",
54+
message: "Failed to resolve batch child download URL",
55+
validationErrors: [],
56+
},
57+
},
58+
{ status: 500 },
59+
)
60+
}
61+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
// GET /api/v1/finance/cost-results/exports/{jobId}/children — enumerate a
2+
// batch-tracking parent export job's child jobs (status + download URL once
3+
// ready). Only meaningful for batch exports (see CostSheetExportJobInfo.isBatch).
4+
5+
import { NextRequest, NextResponse } from "next/server"
6+
import { getCostCalcClient, createMetadataFromRequest, isGrpcError, handleGrpcError } from "@/lib/grpc"
7+
8+
type Ctx = { params: Promise<{ jobId: string }> }
9+
10+
export async function GET(request: NextRequest, ctx: Ctx) {
11+
try {
12+
const { jobId } = await ctx.params
13+
const metadata = createMetadataFromRequest(request)
14+
const client = getCostCalcClient()
15+
16+
const response = await client.listCostSheetExportBatchChildren({ parentJobId: jobId }, metadata)
17+
if (response.base?.isSuccess === false) {
18+
return NextResponse.json(
19+
{ base: response.base },
20+
{ status: Number(response.base?.statusCode ?? "400") || 400 },
21+
)
22+
}
23+
24+
return NextResponse.json({
25+
base: response.base,
26+
data: { children: response.children || [] },
27+
})
28+
} catch (error) {
29+
if (isGrpcError(error)) return handleGrpcError(error)
30+
return NextResponse.json(
31+
{
32+
base: {
33+
isSuccess: false,
34+
statusCode: "500",
35+
message: "Failed to list export batch children",
36+
validationErrors: [],
37+
},
38+
},
39+
{ status: 500 },
40+
)
41+
}
42+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
// GET /api/v1/finance/cost-results/exports/{jobId}/download-all — bundle
2+
// every completed child artifact of a batch export job into one zip and
3+
// stream it back as a native browser download.
4+
//
5+
// DownloadExportBatchZip has no BaseResponse envelope on success (see the
6+
// proto comment on DownloadExportBatchZipResponse), so gRPC errors surface
7+
// as thrown errors here rather than an `isSuccess: false` base field.
8+
9+
import { NextRequest, NextResponse } from "next/server"
10+
import { getCostCalcClient, createMetadataFromRequest, isGrpcError, handleGrpcError } from "@/lib/grpc"
11+
12+
type Ctx = { params: Promise<{ jobId: string }> }
13+
14+
export async function GET(request: NextRequest, ctx: Ctx) {
15+
try {
16+
const { jobId } = await ctx.params
17+
const metadata = createMetadataFromRequest(request)
18+
const client = getCostCalcClient()
19+
20+
const response = await client.downloadExportBatchZip({ parentJobId: jobId }, metadata)
21+
if (!response.zipData || response.zipData.length === 0) {
22+
return NextResponse.json(
23+
{
24+
base: {
25+
isSuccess: false,
26+
statusCode: "404",
27+
message: "No completed files available to download",
28+
validationErrors: [],
29+
},
30+
},
31+
{ status: 404 },
32+
)
33+
}
34+
35+
const fileName = response.fileName || "cost-sheet-export.zip"
36+
return new NextResponse(Buffer.from(response.zipData), {
37+
status: 200,
38+
headers: {
39+
"Content-Type": "application/octet-stream",
40+
"Content-Disposition": `attachment; filename="${fileName}"`,
41+
"Content-Length": String(response.zipData.length),
42+
},
43+
})
44+
} catch (error) {
45+
if (isGrpcError(error)) return handleGrpcError(error)
46+
return NextResponse.json(
47+
{
48+
base: {
49+
isSuccess: false,
50+
statusCode: "500",
51+
message: "Failed to build batch export zip",
52+
validationErrors: [],
53+
},
54+
},
55+
{ status: 500 },
56+
)
57+
}
58+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
// GET /api/v1/finance/cost-results/exports/{jobId}/download — redirect to a
2+
// presigned MinIO URL for a finished product cost sheet export.
3+
//
4+
// GetProductCostSheetDownloadURL verifies job ownership and COMPLETED status
5+
// server-side before presigning, so this route only translates the envelope.
6+
7+
import { NextRequest, NextResponse } from "next/server"
8+
import { getCostCalcClient, createMetadataFromRequest, isGrpcError, handleGrpcError } from "@/lib/grpc"
9+
10+
type Ctx = { params: Promise<{ jobId: string }> }
11+
12+
export async function GET(request: NextRequest, ctx: Ctx) {
13+
try {
14+
const { jobId } = await ctx.params
15+
const metadata = createMetadataFromRequest(request)
16+
const client = getCostCalcClient()
17+
18+
const response = await client.getProductCostSheetDownloadURL({ jobId }, metadata)
19+
if (response.base?.isSuccess === false) {
20+
return NextResponse.json(
21+
{ base: response.base },
22+
{ status: Number(response.base?.statusCode ?? "400") || 400 },
23+
)
24+
}
25+
const url = response.data?.url ?? ""
26+
if (!url) {
27+
return NextResponse.json(
28+
{
29+
base: {
30+
isSuccess: false,
31+
statusCode: "404",
32+
message: "Download URL unavailable",
33+
validationErrors: [],
34+
},
35+
},
36+
{ status: 404 },
37+
)
38+
}
39+
return NextResponse.redirect(url, { status: 302 })
40+
} catch (error) {
41+
if (isGrpcError(error)) return handleGrpcError(error)
42+
return NextResponse.json(
43+
{
44+
base: {
45+
isSuccess: false,
46+
statusCode: "500",
47+
message: "Failed to resolve download URL",
48+
validationErrors: [],
49+
},
50+
},
51+
{ status: 500 },
52+
)
53+
}
54+
}

0 commit comments

Comments
 (0)