Prosento_RepEx / frontend /src /pages /LayoutTemplatesPage.tsx
ChristopherJKoen's picture
Add page template system and UI
25058c7
import { useMemo, useState, useEffect } from "react";
import { Link, useNavigate, useSearchParams } from "react-router-dom";
import {
ArrowLeft,
Download,
Edit3,
Grid,
Image,
Layout,
Plus,
Settings,
Table,
Trash2,
} from "react-feather";
import { putJson, request } from "../lib/api";
import {
BUILTIN_PAGE_TEMPLATES,
createCustomTemplateId,
mergePageTemplates,
} from "../lib/pageTemplates";
import { ensureSections, flattenSections } from "../lib/sections";
import { buildSessionQuery, getSessionId, setStoredSessionId } from "../lib/session";
import type { JobsheetSection, PageTemplateDefinition, Session } from "../types/session";
import { InfoMenu } from "../components/InfoMenu";
import { PageFooter } from "../components/PageFooter";
import { PageHeader } from "../components/PageHeader";
import { PageShell } from "../components/PageShell";
type Variant = "full" | "photos";
type PhotoLayout = "auto" | "two-column" | "stacked";
const DEFAULT_VARIANT: Variant = "full";
const DEFAULT_PHOTO_LAYOUT: PhotoLayout = "auto";
export default function LayoutTemplatesPage() {
const [searchParams] = useSearchParams();
const sessionId = getSessionId(searchParams.toString());
const sessionQuery = buildSessionQuery(sessionId);
const navigate = useNavigate();
const [session, setSession] = useState<Session | null>(null);
const [sections, setSections] = useState<JobsheetSection[]>([]);
const [templates, setTemplates] = useState<PageTemplateDefinition[]>(
BUILTIN_PAGE_TEMPLATES,
);
const [customTemplates, setCustomTemplates] = useState<PageTemplateDefinition[]>(
[],
);
const [newName, setNewName] = useState("");
const [newDescription, setNewDescription] = useState("");
const [newVariant, setNewVariant] = useState<Variant>(DEFAULT_VARIANT);
const [newPhotoLayout, setNewPhotoLayout] =
useState<PhotoLayout>(DEFAULT_PHOTO_LAYOUT);
const [newBlank, setNewBlank] = useState(false);
const [status, setStatus] = useState("");
const [isSaving, setIsSaving] = useState(false);
useEffect(() => {
if (!sessionId) {
setStatus("No active session found.");
return;
}
setStoredSessionId(sessionId);
async function load() {
try {
const [data, sectionResp] = await Promise.all([
request<Session>(`/sessions/${sessionId}`),
request<{ sections: JobsheetSection[] }>(`/sessions/${sessionId}/sections`),
]);
setSession(data);
setSections(ensureSections(sectionResp.sections));
const merged = mergePageTemplates(data.page_templates);
setTemplates(merged);
setCustomTemplates(merged.filter((template) => template.source === "custom"));
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to load templates.";
setStatus(message);
}
}
load();
}, [sessionId]);
const flatPages = useMemo(
() => flattenSections(ensureSections(sections)),
[sections],
);
const saveTemplates = async (nextTemplates: PageTemplateDefinition[]) => {
if (!sessionId) return;
setIsSaving(true);
setStatus("Saving templates...");
try {
const payload = nextTemplates.map((template) => ({
id: template.id,
name: template.name,
description: template.description ?? "",
blank: Boolean(template.blank),
variant: (template.variant ?? DEFAULT_VARIANT) as Variant,
photo_layout: (template.photo_layout ?? DEFAULT_PHOTO_LAYOUT) as PhotoLayout,
}));
const response = await putJson<{ page_templates: PageTemplateDefinition[] }>(
`/sessions/${sessionId}/page-templates`,
{ page_templates: payload },
);
const merged = mergePageTemplates(response.page_templates);
setTemplates(merged);
setCustomTemplates(merged.filter((template) => template.source === "custom"));
setSession((prev) =>
prev
? { ...prev, page_templates: response.page_templates ?? payload }
: prev,
);
setStatus("Templates saved.");
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to save templates.";
setStatus(message);
} finally {
setIsSaving(false);
}
};
const addTemplate = async () => {
const name = newName.trim();
if (!name) {
setStatus("Template name is required.");
return;
}
const nextTemplate: PageTemplateDefinition = {
id: createCustomTemplateId(),
name,
description: newDescription.trim(),
blank: newBlank,
variant: newVariant,
photo_layout: newPhotoLayout,
source: "custom",
};
await saveTemplates([...customTemplates, nextTemplate]);
setNewName("");
setNewDescription("");
setNewVariant(DEFAULT_VARIANT);
setNewPhotoLayout(DEFAULT_PHOTO_LAYOUT);
setNewBlank(false);
};
const updateCustomTemplate = (
templateId: string,
patch: Partial<PageTemplateDefinition>,
) => {
setCustomTemplates((prev) =>
prev.map((template) =>
template.id === templateId ? { ...template, ...patch } : template,
),
);
};
const removeCustomTemplate = async (templateId: string) => {
const next = customTemplates.filter((template) => template.id !== templateId);
await saveTemplates(next);
};
const persistCustomTemplateEdits = async () => {
await saveTemplates(customTemplates);
};
return (
<PageShell>
<PageHeader
title="RepEx - Report Express"
subtitle="Template Library"
right={
<div className="flex items-center gap-2">
<Link
to={`/edit-layouts${sessionQuery}`}
className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
>
<ArrowLeft className="h-4 w-4" />
Back
</Link>
<InfoMenu sessionQuery={sessionQuery} />
</div>
}
/>
<nav className="mb-6" aria-label="Report workflow navigation">
<div className="flex flex-wrap gap-2">
<Link
to={`/input-data${sessionQuery}`}
className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
>
<Table className="h-4 w-4" />
Input Data
</Link>
<Link
to={`/report-viewer${sessionQuery}`}
className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
>
<Layout className="h-4 w-4" />
Report Viewer
</Link>
<Link
to={`/image-placement${sessionQuery}`}
className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
>
<Image className="h-4 w-4" />
Image Placement
</Link>
<Link
to={`/edit-report${sessionQuery}`}
className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
>
<Edit3 className="h-4 w-4" />
Edit Report
</Link>
<Link
to={`/edit-layouts${sessionQuery}`}
className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
>
<Grid className="h-4 w-4" />
Edit Page Layouts
</Link>
<span className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-900 px-4 py-2 text-sm font-semibold text-white">
<Settings className="h-4 w-4" />
Template Library
</span>
<Link
to={`/export${sessionQuery}`}
className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
>
<Download className="h-4 w-4" />
Export
</Link>
</div>
</nav>
<section className="mb-6 rounded-lg border border-gray-200 bg-white p-4">
<h2 className="text-lg font-semibold text-gray-900">Current Usage</h2>
<p className="text-sm text-gray-600">
{sections.length} sections and {flatPages.length} pages currently use page templates.
</p>
</section>
<section className="mb-6 rounded-lg border border-gray-200 bg-white p-4">
<h2 className="text-lg font-semibold text-gray-900">Create Custom Template</h2>
<p className="text-sm text-gray-600 mb-3">
Define a reusable page template, name it, and apply it when adding pages in
Edit Page Layouts.
</p>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
<label className="text-sm text-gray-700">
Template Name
<input
type="text"
value={newName}
onChange={(event) => setNewName(event.target.value)}
className="mt-1 w-full rounded-md border border-gray-200 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-200"
placeholder="My Custom Template"
/>
</label>
<label className="text-sm text-gray-700">
Variant
<select
value={newVariant}
onChange={(event) => setNewVariant(event.target.value as Variant)}
className="mt-1 w-full rounded-md border border-gray-200 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-200"
>
<option value="full">Full</option>
<option value="photos">Photos only</option>
</select>
</label>
<label className="text-sm text-gray-700">
Photo Layout
<select
value={newPhotoLayout}
onChange={(event) =>
setNewPhotoLayout(event.target.value as PhotoLayout)
}
className="mt-1 w-full rounded-md border border-gray-200 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-200"
>
<option value="auto">Auto</option>
<option value="two-column">Two column</option>
<option value="stacked">Stacked</option>
</select>
</label>
<label className="text-sm text-gray-700 md:col-span-2 lg:col-span-2">
Description
<input
type="text"
value={newDescription}
onChange={(event) => setNewDescription(event.target.value)}
className="mt-1 w-full rounded-md border border-gray-200 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-200"
placeholder="Optional notes for the template"
/>
</label>
<label className="inline-flex items-center gap-2 self-end text-sm text-gray-700">
<input
type="checkbox"
checked={newBlank}
onChange={(event) => setNewBlank(event.target.checked)}
/>
Blank page
</label>
</div>
<button
type="button"
onClick={() => {
void addTemplate();
}}
disabled={isSaving}
className="mt-4 inline-flex items-center gap-2 rounded-lg bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-700 transition disabled:opacity-50 disabled:cursor-not-allowed"
>
<Plus className="h-4 w-4" />
Create template
</button>
</section>
<section className="rounded-lg border border-gray-200 bg-white p-4">
<div className="flex items-center justify-between gap-3 mb-3">
<h2 className="text-lg font-semibold text-gray-900">Saved Templates</h2>
<button
type="button"
onClick={() => {
void persistCustomTemplateEdits();
}}
disabled={isSaving}
className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition disabled:opacity-50 disabled:cursor-not-allowed"
>
Save edits
</button>
</div>
<div className="space-y-3">
{templates.map((template) => {
const isCustom = template.source === "custom";
return (
<div
key={template.id}
className="rounded-lg border border-gray-200 bg-gray-50 p-3"
>
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-xs font-semibold text-gray-500">{template.id}</div>
<span
className={[
"inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-semibold",
isCustom
? "bg-emerald-50 text-emerald-700 border border-emerald-200"
: "bg-gray-100 text-gray-600 border border-gray-200",
].join(" ")}
>
{isCustom ? "Custom" : "Built-in"}
</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-2 mt-2">
<label className="text-xs text-gray-600 lg:col-span-2">
Name
<input
type="text"
value={template.name}
disabled={!isCustom}
onChange={(event) =>
updateCustomTemplate(template.id, { name: event.target.value })
}
className="mt-1 w-full rounded-md border border-gray-200 px-2 py-1 text-sm disabled:bg-gray-100 disabled:text-gray-500"
/>
</label>
<label className="text-xs text-gray-600">
Variant
<select
value={template.variant ?? DEFAULT_VARIANT}
disabled={!isCustom}
onChange={(event) =>
updateCustomTemplate(template.id, {
variant: event.target.value as Variant,
})
}
className="mt-1 w-full rounded-md border border-gray-200 px-2 py-1 text-sm disabled:bg-gray-100 disabled:text-gray-500"
>
<option value="full">Full</option>
<option value="photos">Photos only</option>
</select>
</label>
<label className="text-xs text-gray-600">
Photo Layout
<select
value={template.photo_layout ?? DEFAULT_PHOTO_LAYOUT}
disabled={!isCustom}
onChange={(event) =>
updateCustomTemplate(template.id, {
photo_layout: event.target.value as PhotoLayout,
})
}
className="mt-1 w-full rounded-md border border-gray-200 px-2 py-1 text-sm disabled:bg-gray-100 disabled:text-gray-500"
>
<option value="auto">Auto</option>
<option value="two-column">Two column</option>
<option value="stacked">Stacked</option>
</select>
</label>
<label className="inline-flex items-center gap-2 text-xs text-gray-600 self-end">
<input
type="checkbox"
checked={Boolean(template.blank)}
disabled={!isCustom}
onChange={(event) =>
updateCustomTemplate(template.id, { blank: event.target.checked })
}
/>
Blank page
</label>
</div>
<label className="text-xs text-gray-600 block mt-2">
Description
<input
type="text"
value={template.description ?? ""}
disabled={!isCustom}
onChange={(event) =>
updateCustomTemplate(template.id, {
description: event.target.value,
})
}
className="mt-1 w-full rounded-md border border-gray-200 px-2 py-1 text-sm disabled:bg-gray-100 disabled:text-gray-500"
/>
</label>
{isCustom ? (
<button
type="button"
onClick={() => {
void removeCustomTemplate(template.id);
}}
disabled={isSaving}
className="mt-2 inline-flex items-center gap-1 rounded-md border border-red-200 bg-red-50 px-2 py-1 text-xs font-semibold text-red-700 hover:bg-red-100 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Trash2 className="h-3.5 w-3.5" />
Delete template
</button>
) : null}
</div>
);
})}
</div>
</section>
{status ? <p className="mt-4 text-sm text-gray-600">{status}</p> : null}
<PageFooter note="Templates are stored per session and available when adding new pages." />
</PageShell>
);
}