feat(timesheets): move upload status to its own unlinked page

Timesheets now shows only entries + filters; an "Upload status" button
next to Import Timesheets opens the sheet-by-sheet processing view on
its own route, not listed in the sidebar.
This commit is contained in:
DEls 2026-09-11 21:33:45 +02:00
parent c3914165af
commit 607de98e89
4 changed files with 157 additions and 98 deletions

View file

@ -1,7 +1,10 @@
import Link from "next/link";
import { ClipboardList } from "lucide-react";
import { pbAdmin } from "@/lib/pb";
import { Topbar } from "@/components/layout/sidebar";
import { Button } from "@/components/ui/button";
import { ImportTimesheetsButton } from "@/components/import-timesheets-button";
import { TimesheetsView, type TimesheetEntryRow, type UploadRow } from "./timesheets-view";
import { TimesheetsView, type TimesheetEntryRow } from "./timesheets-view";
export const dynamic = "force-dynamic";
@ -16,9 +19,7 @@ export default async function TimesheetsPage() {
pb.collection("employees").getFullList({ sort: "name" }),
pb.collection("sites").getFullList({ sort: "name" }),
pb.collection("timesheets").getFullList({
sort: "-date_uploaded",
fields:
"id,source_file,date_uploaded,processing_status,rows_created,rows_failed,last_error",
fields: "id,processing_status",
}),
]);
@ -48,34 +49,31 @@ export default async function TimesheetsPage() {
};
});
const uploads: UploadRow[] = documents.map((d) => ({
id: d.id,
sourceFile: (d.source_file as string) ?? "",
dateUploaded: (d.date_uploaded as string) ?? "",
processingStatus: (d.processing_status as string) ?? "",
rowsCreated: (d.rows_created as number) ?? 0,
rowsFailed: (d.rows_failed as number) ?? 0,
lastError: (d.last_error as string) ?? "",
}));
return (
<>
<Topbar
title="Timesheets"
subtitle="Every entry, every site, every source — flat list with upload status surfaced."
subtitle="Every entry, every site, every source."
right={
<ImportTimesheetsButton
sites={sites.map((s) => ({
id: s.id,
name: s.name,
project_code: s.project_code,
}))}
/>
<>
<Button variant="outline" size="sm" asChild className="gap-1.5">
<Link href="/dashboard/workforce/timesheets/upload-status">
<ClipboardList className="h-3.5 w-3.5" />
Upload status
</Link>
</Button>
<ImportTimesheetsButton
sites={sites.map((s) => ({
id: s.id,
name: s.name,
project_code: s.project_code,
}))}
/>
</>
}
/>
<TimesheetsView
rows={rows}
uploads={uploads}
sites={sites.map((s) => ({
id: s.id,
name: s.name,

View file

@ -47,24 +47,11 @@ export interface TimesheetEntryRow {
documentStatus?: string | null;
}
export interface UploadRow {
id: string;
sourceFile: string;
/** ISO date string (YYYY-MM-DD) or empty. */
dateUploaded: string;
processingStatus: string;
rowsCreated: number;
rowsFailed: number;
lastError: string;
}
export function TimesheetsView({
rows,
uploads,
sites,
}: {
rows: TimesheetEntryRow[];
uploads: UploadRow[];
sites: { id: string; name: string; project_code: string }[];
}) {
const [siteFilter, setSiteFilter] = React.useState<string>("all");
@ -197,68 +184,6 @@ export function TimesheetsView({
</CardContent>
</Card>
<Card className="overflow-hidden">
<CardHeader className="pb-2">
<CardTitle className="text-base">Upload status</CardTitle>
<p className="mt-1 text-xs text-muted-foreground">
Every uploaded sheet and whether it processed cleanly — separate from
the individual hour entries below.
</p>
</CardHeader>
<CardContent className="p-0">
{uploads.length === 0 ? (
<div className="px-4 py-10 text-center text-sm text-muted-foreground">
No timesheets uploaded yet.
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>File</TableHead>
<TableHead>Uploaded</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Rows created</TableHead>
<TableHead className="text-right">Rows failed</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{uploads.map((u) => (
<TableRow key={u.id}>
<TableCell className="max-w-[220px] truncate text-sm" title={u.sourceFile}>
{u.sourceFile || "—"}
</TableCell>
<TableCell className="text-xs tabular-nums">
{u.dateUploaded ? format(parseISO(u.dateUploaded), "yyyy-MM-dd") : "—"}
</TableCell>
<TableCell>
<Badge
variant="outline"
className={cn(
"capitalize",
u.processingStatus === "failed" &&
"border-red-500/30 bg-red-500/15 text-red-700 dark:text-red-400",
u.processingStatus === "processed" &&
"border-emerald-500/30 bg-emerald-500/15 text-emerald-700 dark:text-emerald-400",
)}
title={u.lastError || undefined}
>
{u.processingStatus || "pending"}
</Badge>
</TableCell>
<TableCell className="text-right tabular-nums">
{formatNumber(u.rowsCreated)}
</TableCell>
<TableCell className="text-right tabular-nums">
{formatNumber(u.rowsFailed)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<Card className="overflow-hidden">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<div>

View file

@ -0,0 +1,45 @@
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
import { pbAdmin } from "@/lib/pb";
import { Topbar } from "@/components/layout/sidebar";
import { Button } from "@/components/ui/button";
import { UploadStatusView, type UploadRow } from "./upload-status-view";
export const dynamic = "force-dynamic";
export default async function UploadStatusPage() {
const pb = await pbAdmin();
const documents = await pb.collection("timesheets").getFullList({
sort: "-date_uploaded",
fields: "id,source_file,date_uploaded,processing_status,rows_created,rows_failed,last_error",
});
const uploads: UploadRow[] = documents.map((d) => ({
id: d.id,
sourceFile: (d.source_file as string) ?? "",
dateUploaded: (d.date_uploaded as string) ?? "",
processingStatus: (d.processing_status as string) ?? "",
rowsCreated: (d.rows_created as number) ?? 0,
rowsFailed: (d.rows_failed as number) ?? 0,
lastError: (d.last_error as string) ?? "",
}));
return (
<>
<Topbar
title="Upload status"
subtitle="Every uploaded timesheet and whether it processed cleanly."
right={
<Button variant="outline" size="sm" asChild className="gap-1.5">
<Link href="/dashboard/workforce/timesheets">
<ArrowLeft className="h-3.5 w-3.5" />
Back to Timesheets
</Link>
</Button>
}
/>
<UploadStatusView uploads={uploads} />
</>
);
}

View file

@ -0,0 +1,91 @@
import { format, parseISO } from "date-fns";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { cn, formatNumber } from "@/lib/utils";
export interface UploadRow {
id: string;
sourceFile: string;
/** ISO date string (YYYY-MM-DD) or empty. */
dateUploaded: string;
processingStatus: string;
rowsCreated: number;
rowsFailed: number;
lastError: string;
}
export function UploadStatusView({ uploads }: { uploads: UploadRow[] }) {
return (
<div className="space-y-6 p-4 sm:p-6">
<Card className="overflow-hidden">
<CardHeader className="pb-2">
<CardTitle className="text-base">Upload status</CardTitle>
<p className="mt-1 text-xs text-muted-foreground">
Every uploaded sheet and whether it processed cleanly — separate from
the individual hour entries on the Timesheets page.
</p>
</CardHeader>
<CardContent className="p-0">
{uploads.length === 0 ? (
<div className="px-4 py-10 text-center text-sm text-muted-foreground">
No timesheets uploaded yet.
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>File</TableHead>
<TableHead>Uploaded</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Rows created</TableHead>
<TableHead className="text-right">Rows failed</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{uploads.map((u) => (
<TableRow key={u.id}>
<TableCell className="max-w-[220px] truncate text-sm" title={u.sourceFile}>
{u.sourceFile || "—"}
</TableCell>
<TableCell className="text-xs tabular-nums">
{u.dateUploaded ? format(parseISO(u.dateUploaded), "yyyy-MM-dd") : "—"}
</TableCell>
<TableCell>
<Badge
variant="outline"
className={cn(
"capitalize",
u.processingStatus === "failed" &&
"border-red-500/30 bg-red-500/15 text-red-700 dark:text-red-400",
u.processingStatus === "processed" &&
"border-emerald-500/30 bg-emerald-500/15 text-emerald-700 dark:text-emerald-400",
)}
title={u.lastError || undefined}
>
{u.processingStatus || "pending"}
</Badge>
</TableCell>
<TableCell className="text-right tabular-nums">
{formatNumber(u.rowsCreated)}
</TableCell>
<TableCell className="text-right tabular-nums">
{formatNumber(u.rowsFailed)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
);
}