Spaces:
Sleeping
Sleeping
| 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> | |
| ); | |
| } | |