Skip to content

Custom frontend routes and screens

View as Markdown

A custom screen combines generated records and app-owned actions into one domain workflow. The generated table routes remain the standard create, edit, filter, and export surfaces; the custom route adds only the interaction the application needs.

This page builds a protected project-progress screen, loads row-scoped table data, invokes the typed completion endpoint, and connects the screen to application navigation. The same structure supports dashboards, review queues, import wizards, master-detail workspaces, and other multi-record workflows.

Add a protected project-progress screen. Load visible projects and tasks through Sapporta's table client, show explicit loading, empty, ready, pending, success, and error states, complete tasks through the typed client, and link back to generated tables.

Create packages/frontend/src/ProjectProgress.tsx. fetchTableRows() calls the generated /api/tables/:tableName routes, so server-side row visibility still applies. The custom endpoint owns completion because it changes both task status and history.

import { useCallback, useEffect, useMemo, useState } from "react";
import { fetchTableRows } from "@sapporta/frontend";
import { ApiError } from "@sapporta/shared/client";
import { Button } from "@sapporta/ui";
import { Link } from "react-router-dom";
import { taskActionsApi } from "./api";
type Project = { id: number; name: string };
type Task = {
id: number;
project_id: number;
title: string;
status: string;
};
type ErrorBody = { error: string; code: string };
function isErrorBody(value: unknown): value is ErrorBody {
return (
typeof value === "object" &&
value !== null &&
"error" in value &&
typeof value.error === "string" &&
"code" in value &&
typeof value.code === "string"
);
}
function projectsFrom(rows: Record<string, unknown>[]): Project[] {
return rows.flatMap((row) =>
typeof row.id === "number" && typeof row.name === "string"
? [{ id: row.id, name: row.name }]
: [],
);
}
function tasksFrom(rows: Record<string, unknown>[]): Task[] {
return rows.flatMap((row) =>
typeof row.id === "number" &&
typeof row.project_id === "number" &&
typeof row.title === "string" &&
typeof row.status === "string"
? [
{
id: row.id,
project_id: row.project_id,
title: row.title,
status: row.status,
},
]
: [],
);
}
export function ProjectProgress() {
const [projects, setProjects] = useState<Project[]>([]);
const [tasks, setTasks] = useState<Task[]>([]);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [pendingTaskId, setPendingTaskId] = useState<number | null>(null);
const refresh = useCallback(async () => {
setLoadError(null);
try {
const [projectResult, taskResult] = await Promise.all([
fetchTableRows({ tableName: "projects", limit: 100 }),
fetchTableRows({ tableName: "tasks", limit: 100 }),
]);
setProjects(projectsFrom(projectResult.data));
setTasks(tasksFrom(taskResult.data));
} catch {
setLoadError("Project progress could not be loaded.");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
const tasksByProject = useMemo(() => {
const grouped = new Map<number, Task[]>();
for (const task of tasks) {
grouped.set(task.project_id, [
...(grouped.get(task.project_id) ?? []),
task,
]);
}
return grouped;
}, [tasks]);
async function handleComplete(task: Task) {
setPendingTaskId(task.id);
setActionError(null);
setNotice(null);
try {
await taskActionsApi.completeTask({
params: { id: task.id },
body: {},
});
await refresh();
setNotice(`${task.title} is complete.`);
} catch (error) {
if (error instanceof ApiError && isErrorBody(error.body)) {
setActionError(error.body.error);
} else {
setActionError("The task could not be completed.");
}
} finally {
setPendingTaskId(null);
}
}
if (loading) {
return <p className="p-6 text-sm text-sap-muted">Loading progress…</p>;
}
if (loadError) {
return (
<div className="p-6">
<p role="alert" className="text-sm text-red-700">
{loadError}
</p>
<Button className="mt-3" onClick={() => void refresh()}>
Retry
</Button>
</div>
);
}
if (projects.length === 0) {
return (
<div className="p-6">
<h1 className="text-xl font-semibold">Project progress</h1>
<p className="mt-2 text-sm text-sap-muted">No projects are visible.</p>
<Link className="mt-4 inline-block underline" to="/tables/projects">
Create a project
</Link>
</div>
);
}
return (
<main className="space-y-4 p-6">
<div className="flex items-center justify-between gap-4">
<div>
<h1 className="text-xl font-semibold">Project progress</h1>
<p className="text-sm text-sap-muted">
Complete tasks here. Edit records in the generated tables.
</p>
</div>
<Link className="text-sm underline" to="/tables/tasks">
Open Tasks
</Link>
</div>
{notice && (
<p role="status" className="text-sm text-green-700">
{notice}
</p>
)}
{actionError && (
<p role="alert" className="text-sm text-red-700">
{actionError}
</p>
)}
{projects.map((project) => {
const projectTasks = tasksByProject.get(project.id) ?? [];
const completed = projectTasks.filter(
(task) => task.status === "completed",
).length;
return (
<section key={project.id} className="rounded-lg border p-4">
<div className="flex items-center justify-between gap-4">
<h2 className="font-medium">{project.name}</h2>
<span className="text-sm text-sap-muted">
{completed} of {projectTasks.length} complete
</span>
</div>
<ul className="mt-3 divide-y">
{projectTasks.map((task) => (
<li key={task.id} className="flex items-center gap-3 py-2">
<span className="min-w-0 flex-1 truncate">{task.title}</span>
<span className="text-xs text-sap-muted">{task.status}</span>
{task.status !== "completed" && (
<Button
size="sm"
disabled={pendingTaskId === task.id}
onClick={() => void handleComplete(task)}
>
{pendingTaskId === task.id ? "Completing…" : "Complete"}
</Button>
)}
</li>
))}
</ul>
</section>
);
})}
</main>
);
}

The screen intentionally loads at most 100 records for an introductory dashboard. A larger dataset should move aggregation and pagination into a report or custom endpoint instead of downloading every row.

Client filters, hidden fields, and route parameters are product constraints, not authorization. Do not add workspace_id or scoped_to_user_id to this component. The generated table APIs and custom workflow enforce authority on the server.

Add the protected route and navigation item

Section titled “Add the protected route and navigation item”

Update packages/frontend/src/App.tsx using the project’s existing exported values:

import { Navigate, Route } from "react-router-dom";
import type { Navigation } from "@sapporta/frontend/shell";
import { ChartNoAxesColumnIncreasing, Sparkles } from "lucide-react";
import { ProjectProgress } from "./ProjectProgress";
import { Welcome } from "./Welcome";
const welcomePath = "/welcome";
export const appNavigation: Navigation = [
{
label: "Views",
items: [
{ label: "Welcome", icon: Sparkles, to: welcomePath },
{
label: "Project progress",
icon: ChartNoAxesColumnIncreasing,
to: "/projects/progress",
},
],
},
];
export const appHomeRoute = (
<Route index element={<Navigate to={welcomePath} replace />} />
);
export const appProtectedRoutes = (
<>
<Route path="projects/progress" element={<ProjectProgress />} />
</>
);

Keep the existing appPublicRoutes export and any other routes in the file. Protected routes render after the application has loaded the authenticated session and active workspace. A public route belongs in appPublicRoutes only when both the page and its data are intentionally anonymous.

Run the frontend build, then start the app:

Terminal window
pnpm build
pnpm dev

Use the canonical task dataset. Open /projects/progress, complete Audit launch checklist, and follow the Tasks link to the generated table. The project count should increase, the task status should read completed, and the generated Task history should contain the completion event.

Capture the empty state separately only if that state needs documentation:

The application now has one protected workflow screen without replacing the generated record system. Table reads remain row-scoped, completion remains a server transaction, and ordinary editing stays on generated routes. The less obvious design boundary is scale: client-side aggregation is suitable for a bounded tutorial dataset, while larger workloads belong in report routes. From here, add report-backed summaries, role-aware action visibility, or a Grid-based worklist when selection and bulk commands become central to the workflow.