Spaces:
Sleeping
Sleeping
| import { useEffect, useMemo, useRef, useState } from "react"; | |
| import { Link, useNavigate, useSearchParams } from "react-router-dom"; | |
| import { | |
| ArrowLeft, | |
| ChevronDown, | |
| ChevronUp, | |
| Download, | |
| Edit3, | |
| Grid, | |
| Layout, | |
| Image, | |
| Plus, | |
| Table, | |
| Trash2, | |
| } from "react-feather"; | |
| import { putJson, request } from "../lib/api"; | |
| import { | |
| applyPageTemplateToPage, | |
| inferPageTemplateId, | |
| mergePageTemplates, | |
| } from "../lib/pageTemplates"; | |
| import { BASE_W } from "../lib/report"; | |
| import { ensureSections, flattenSections, replacePage } from "../lib/sections"; | |
| import { buildSessionQuery, getSessionId, setStoredSessionId } from "../lib/session"; | |
| import type { | |
| JobsheetSection, | |
| PageTemplateDefinition, | |
| Session, | |
| } from "../types/session"; | |
| import { PageFooter } from "../components/PageFooter"; | |
| import { PageHeader } from "../components/PageHeader"; | |
| import { PageShell } from "../components/PageShell"; | |
| import { InfoMenu } from "../components/InfoMenu"; | |
| import { ReportPageCanvas } from "../components/ReportPageCanvas"; | |
| import { SaveBeforeLeaveDialog } from "../components/SaveBeforeLeaveDialog"; | |
| export default function EditLayoutsPage() { | |
| 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 [status, setStatus] = useState(""); | |
| const [isSaving, setIsSaving] = useState(false); | |
| const [saveState, setSaveState] = useState< | |
| "saved" | "saving" | "pending" | "error" | |
| >("saved"); | |
| const [pageTemplates, setPageTemplates] = useState<PageTemplateDefinition[]>( | |
| mergePageTemplates(undefined), | |
| ); | |
| const [newPageTemplateBySection, setNewPageTemplateBySection] = useState< | |
| Record<string, string> | |
| >({}); | |
| const [collapsedSections, setCollapsedSections] = useState<Record<string, boolean>>( | |
| {}, | |
| ); | |
| const [pendingNavigationTarget, setPendingNavigationTarget] = useState< | |
| string | null | |
| >(null); | |
| const canModify = Boolean(sessionId) && !isSaving; | |
| const saveTimerRef = useRef<number | null>(null); | |
| const savePromiseRef = useRef<Promise<void> | null>(null); | |
| const lastSavedRef = useRef<string>(""); | |
| const pendingSaveRef = useRef<string>(""); | |
| useEffect(() => { | |
| if (!sessionId) { | |
| setStatus("No active session found."); | |
| return; | |
| } | |
| setStoredSessionId(sessionId); | |
| async function load() { | |
| try { | |
| const data = await request<Session>(`/sessions/${sessionId}`); | |
| setSession(data); | |
| setPageTemplates(mergePageTemplates(data.page_templates)); | |
| const sectionResp = await request<{ sections: JobsheetSection[] }>( | |
| `/sessions/${sessionId}/sections`, | |
| ); | |
| const normalized = ensureSections(sectionResp.sections); | |
| setSections(normalized); | |
| setNewPageTemplateBySection((prev) => { | |
| const next = { ...prev }; | |
| normalized.forEach((section) => { | |
| if (!next[section.id]) { | |
| next[section.id] = "repex:blank"; | |
| } | |
| }); | |
| return next; | |
| }); | |
| lastSavedRef.current = JSON.stringify(normalized); | |
| pendingSaveRef.current = lastSavedRef.current; | |
| setSaveState("saved"); | |
| } catch (err) { | |
| const message = | |
| err instanceof Error ? err.message : "Failed to load session."; | |
| setStatus(message); | |
| } | |
| } | |
| load(); | |
| }, [sessionId]); | |
| const flatPages = useMemo( | |
| () => flattenSections(ensureSections(sections)), | |
| [sections], | |
| ); | |
| const totalPages = useMemo( | |
| () => Math.max(1, flatPages.length), | |
| [flatPages.length], | |
| ); | |
| const totalSections = sections.length; | |
| const previewWidth = 220; | |
| const previewScale = previewWidth / BASE_W; | |
| function findTemplate(templateId?: string) { | |
| const fallback = mergePageTemplates(undefined)[0]; | |
| const selectedId = (templateId || "").trim(); | |
| if (selectedId) { | |
| const match = pageTemplates.find((template) => template.id === selectedId); | |
| if (match) return match; | |
| } | |
| return ( | |
| pageTemplates.find((template) => template.id === "repex:standard") ?? | |
| pageTemplates[0] ?? | |
| fallback | |
| ); | |
| } | |
| function hasExplicitPhotos(source: JobsheetSection[]) { | |
| return source.some((section) => | |
| (section.pages ?? []).some((page) => (page.photo_ids ?? []).length > 0), | |
| ); | |
| } | |
| function flattenPhotoIds(source: JobsheetSection[]) { | |
| const seen = new Set<string>(); | |
| const result: string[] = []; | |
| source.forEach((section) => { | |
| (section.pages ?? []).forEach((page) => { | |
| (page.photo_ids ?? []).forEach((photoId) => { | |
| if (!seen.has(photoId)) { | |
| seen.add(photoId); | |
| result.push(photoId); | |
| } | |
| }); | |
| }); | |
| }); | |
| return result; | |
| } | |
| async function triggerAutoSave() { | |
| if (!sessionId || savePromiseRef.current) return; | |
| const snapshot = pendingSaveRef.current; | |
| if (!snapshot || snapshot === lastSavedRef.current) return; | |
| await saveLayout(sections, undefined, true); | |
| } | |
| useEffect(() => { | |
| if (!sessionId) return; | |
| const snapshot = JSON.stringify(sections); | |
| if (snapshot === lastSavedRef.current) { | |
| if (!isSaving) setSaveState("saved"); | |
| return; | |
| } | |
| pendingSaveRef.current = snapshot; | |
| if (!isSaving) setSaveState("pending"); | |
| if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current); | |
| saveTimerRef.current = window.setTimeout(() => { | |
| void triggerAutoSave(); | |
| }, 800); | |
| }, [sections, sessionId, isSaving]); | |
| useEffect(() => { | |
| if (!sections.length) return; | |
| setNewPageTemplateBySection((prev) => { | |
| const next: Record<string, string> = {}; | |
| sections.forEach((section) => { | |
| next[section.id] = prev[section.id] ?? "repex:blank"; | |
| }); | |
| return next; | |
| }); | |
| }, [sections]); | |
| async function saveLayout( | |
| next: JobsheetSection[], | |
| nextSelectedIds?: string[], | |
| silent = false, | |
| ) { | |
| if (!sessionId) return; | |
| if (savePromiseRef.current) { | |
| await savePromiseRef.current; | |
| } | |
| const snapshot = JSON.stringify(next); | |
| if (snapshot === lastSavedRef.current && nextSelectedIds === undefined) { | |
| if (!isSaving) setSaveState("saved"); | |
| return; | |
| } | |
| const promise = (async () => { | |
| setIsSaving(true); | |
| setSaveState("saving"); | |
| if (!silent) { | |
| setStatus("Saving layout changes..."); | |
| } | |
| try { | |
| const requests: Promise<unknown>[] = [ | |
| putJson<{ sections: JobsheetSection[] }>( | |
| `/sessions/${sessionId}/sections`, | |
| { sections: next }, | |
| ), | |
| ]; | |
| if (nextSelectedIds !== undefined) { | |
| requests.push( | |
| putJson<Session>(`/sessions/${sessionId}/selection`, { | |
| selected_photo_ids: nextSelectedIds, | |
| }), | |
| ); | |
| } | |
| const [pagesResp, sessionResp] = await Promise.all(requests); | |
| const updatedSections = | |
| (pagesResp as { sections?: JobsheetSection[] }).sections ?? next; | |
| const updatedSession = sessionResp as Session | undefined; | |
| const updated = ensureSections(updatedSections); | |
| setSections(updated); | |
| lastSavedRef.current = JSON.stringify(updated); | |
| pendingSaveRef.current = lastSavedRef.current; | |
| setSaveState("saved"); | |
| if (updatedSession) { | |
| setSession(updatedSession); | |
| setPageTemplates(mergePageTemplates(updatedSession.page_templates)); | |
| } | |
| if (!silent) { | |
| setStatus("Layout saved."); | |
| } | |
| } catch (err) { | |
| const message = | |
| err instanceof Error ? err.message : "Failed to save layout."; | |
| setStatus(message); | |
| setSaveState("error"); | |
| } finally { | |
| setIsSaving(false); | |
| if ( | |
| pendingSaveRef.current && | |
| pendingSaveRef.current !== lastSavedRef.current | |
| ) { | |
| if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current); | |
| saveTimerRef.current = window.setTimeout(() => { | |
| void triggerAutoSave(); | |
| }, 200); | |
| } | |
| } | |
| })(); | |
| savePromiseRef.current = promise; | |
| try { | |
| await promise; | |
| } finally { | |
| savePromiseRef.current = null; | |
| } | |
| } | |
| async function handleAddSection() { | |
| const baseTemplate = findTemplate("repex:standard"); | |
| const next = [ | |
| ...sections, | |
| { | |
| id: crypto.randomUUID(), | |
| title: `Section ${sections.length + 1}`, | |
| pages: [applyPageTemplateToPage({ items: [] }, baseTemplate)], | |
| }, | |
| ]; | |
| await saveLayout(next); | |
| } | |
| function toggleSection(sectionId: string) { | |
| setCollapsedSections((prev) => ({ | |
| ...prev, | |
| [sectionId]: !prev[sectionId], | |
| })); | |
| } | |
| function setAllSectionsCollapsed(value: boolean) { | |
| const next: Record<string, boolean> = {}; | |
| sections.forEach((section) => { | |
| next[section.id] = value; | |
| }); | |
| setCollapsedSections(next); | |
| } | |
| async function handleAddPage(sectionIndex: number) { | |
| const section = sections[sectionIndex]; | |
| const templateId = section ? newPageTemplateBySection[section.id] : undefined; | |
| const selectedTemplate = findTemplate(templateId); | |
| const next = sections.map((section, idx) => { | |
| if (idx !== sectionIndex) return section; | |
| const baseTemplate = { | |
| ...(section.pages?.[section.pages.length - 1]?.template ?? {}), | |
| }; | |
| const nextPage = applyPageTemplateToPage( | |
| { items: [], template: baseTemplate }, | |
| selectedTemplate, | |
| ); | |
| return { | |
| ...section, | |
| pages: [...(section.pages ?? []), nextPage], | |
| }; | |
| }); | |
| if (session) { | |
| const nextSelected = hasExplicitPhotos(next) | |
| ? flattenPhotoIds(next) | |
| : session.selected_photo_ids ?? []; | |
| await saveLayout(next, nextSelected); | |
| return; | |
| } | |
| await saveLayout(next); | |
| } | |
| function handleChangePageTemplate( | |
| sectionIndex: number, | |
| pageIndex: number, | |
| templateId: string, | |
| ) { | |
| const selectedTemplate = findTemplate(templateId); | |
| if (!selectedTemplate) return; | |
| setSections((prev) => { | |
| const section = prev[sectionIndex]; | |
| const page = section?.pages?.[pageIndex]; | |
| if (!section || !page) return prev; | |
| const nextPage = applyPageTemplateToPage( | |
| { ...page, template: { ...(page.template ?? {}) } }, | |
| selectedTemplate, | |
| ); | |
| return replacePage(prev, sectionIndex, pageIndex, nextPage); | |
| }); | |
| const label = selectedTemplate.name || selectedTemplate.id; | |
| setStatus(`Applied "${label}" template to page ${pageIndex + 1}.`); | |
| } | |
| async function handleRemovePage(sectionIndex: number, pageIndex: number) { | |
| const next = sections.map((section, idx) => { | |
| if (idx !== sectionIndex) return section; | |
| const pages = [...(section.pages ?? [])]; | |
| if (pages.length <= 1) return section; | |
| pages.splice(pageIndex, 1); | |
| return { ...section, pages: pages.length ? pages : [{ items: [] }] }; | |
| }); | |
| if (session) { | |
| const nextSelected = hasExplicitPhotos(next) | |
| ? flattenPhotoIds(next) | |
| : session.selected_photo_ids ?? []; | |
| await saveLayout(next, nextSelected); | |
| return; | |
| } | |
| await saveLayout(next); | |
| } | |
| async function handleMovePage(sectionIndex: number, pageIndex: number, direction: number) { | |
| const next = sections.map((section, idx) => { | |
| if (idx !== sectionIndex) return section; | |
| const pages = [...(section.pages ?? [])]; | |
| const target = pageIndex + direction; | |
| if (target < 0 || target >= pages.length) return section; | |
| [pages[pageIndex], pages[target]] = [pages[target], pages[pageIndex]]; | |
| return { ...section, pages }; | |
| }); | |
| if (session) { | |
| const nextSelected = hasExplicitPhotos(next) | |
| ? flattenPhotoIds(next) | |
| : session.selected_photo_ids ?? []; | |
| await saveLayout(next, nextSelected); | |
| return; | |
| } | |
| await saveLayout(next); | |
| } | |
| async function saveAndNavigate(target: string) { | |
| if (!sessionId) { | |
| navigate(target); | |
| return; | |
| } | |
| if (saveTimerRef.current) { | |
| window.clearTimeout(saveTimerRef.current); | |
| saveTimerRef.current = null; | |
| } | |
| const snapshot = JSON.stringify(sections); | |
| pendingSaveRef.current = snapshot; | |
| if (snapshot !== lastSavedRef.current || savePromiseRef.current) { | |
| await saveLayout(sections, undefined, true); | |
| } | |
| navigate(target); | |
| } | |
| function requestNavigation(target: string) { | |
| if (saveState === "saving" || saveState === "pending" || isSaving) { | |
| setPendingNavigationTarget(target); | |
| return; | |
| } | |
| void saveAndNavigate(target); | |
| } | |
| async function waitForSaveAndContinue() { | |
| if (!pendingNavigationTarget) return; | |
| const target = pendingNavigationTarget; | |
| setPendingNavigationTarget(null); | |
| await saveAndNavigate(target); | |
| } | |
| function leaveWithoutWaiting() { | |
| if (!pendingNavigationTarget) return; | |
| const target = pendingNavigationTarget; | |
| setPendingNavigationTarget(null); | |
| navigate(target); | |
| } | |
| const saveIndicator = useMemo(() => { | |
| const base = | |
| "inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs font-semibold"; | |
| if (saveState === "saving") { | |
| return ( | |
| <div className={`${base} border-amber-200 bg-amber-50 text-amber-700`}> | |
| <span className="h-2 w-2 rounded-full bg-amber-500 animate-pulse" /> | |
| Saving... | |
| </div> | |
| ); | |
| } | |
| if (saveState === "pending") { | |
| return ( | |
| <div className={`${base} border-amber-200 bg-amber-50 text-amber-700`}> | |
| <span className="h-2 w-2 rounded-full bg-amber-500" /> | |
| Unsaved changes | |
| </div> | |
| ); | |
| } | |
| if (saveState === "error") { | |
| return ( | |
| <div className={`${base} border-red-200 bg-red-50 text-red-700`}> | |
| <span className="h-2 w-2 rounded-full bg-red-500" /> | |
| Save failed | |
| </div> | |
| ); | |
| } | |
| return ( | |
| <div className={`${base} border-emerald-200 bg-emerald-50 text-emerald-700`}> | |
| <span className="h-2 w-2 rounded-full bg-emerald-500" /> | |
| All changes saved | |
| </div> | |
| ); | |
| }, [saveState]); | |
| return ( | |
| <PageShell> | |
| <PageHeader | |
| title="RepEx - Report Express" | |
| subtitle="Edit Page Layouts" | |
| right={ | |
| <div className="flex flex-col items-end gap-2 sm:flex-row sm:items-center"> | |
| {saveIndicator} | |
| <Link | |
| to={`/report-viewer${sessionQuery}`} | |
| onClick={(event) => { | |
| event.preventDefault(); | |
| requestNavigation(`/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" | |
| > | |
| <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}`} | |
| onClick={(event) => { | |
| event.preventDefault(); | |
| requestNavigation(`/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}`} | |
| onClick={(event) => { | |
| event.preventDefault(); | |
| requestNavigation(`/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}`} | |
| onClick={(event) => { | |
| event.preventDefault(); | |
| requestNavigation(`/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}`} | |
| onClick={(event) => { | |
| event.preventDefault(); | |
| requestNavigation(`/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> | |
| <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"> | |
| <Grid className="h-4 w-4" /> | |
| Edit Page Layouts | |
| </span> | |
| <Link | |
| to={`/edit-layouts/templates${sessionQuery}`} | |
| onClick={(event) => { | |
| event.preventDefault(); | |
| requestNavigation(`/edit-layouts/templates${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" | |
| > | |
| Templates | |
| </Link> | |
| <Link | |
| to={`/export${sessionQuery}`} | |
| onClick={(event) => { | |
| event.preventDefault(); | |
| requestNavigation(`/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"> | |
| <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3"> | |
| <div> | |
| <h2 className="text-lg font-semibold text-gray-900">Page Layouts</h2> | |
| <p className="text-sm text-gray-600"> | |
| Add, remove, or reorder pages, then return to the report viewer to edit content. | |
| </p> | |
| <div className="mt-2 text-xs text-gray-500"> | |
| {totalSections} section{totalSections === 1 ? "" : "s"} -{" "} | |
| {totalPages} page{totalPages === 1 ? "" : "s"} | |
| </div> | |
| </div> | |
| <div className="flex flex-wrap gap-2"> | |
| <button | |
| type="button" | |
| onClick={() => setAllSectionsCollapsed(true)} | |
| 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-700 hover:bg-gray-50 transition" | |
| > | |
| Collapse all | |
| </button> | |
| <button | |
| type="button" | |
| onClick={() => setAllSectionsCollapsed(false)} | |
| 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-700 hover:bg-gray-50 transition" | |
| > | |
| Expand all | |
| </button> | |
| <button | |
| type="button" | |
| onClick={handleAddSection} | |
| disabled={!canModify} | |
| className="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" /> | |
| Add section | |
| </button> | |
| </div> | |
| </div> | |
| {status ? ( | |
| <p className="text-sm text-gray-600 mt-3">{status}</p> | |
| ) : null} | |
| </section> | |
| <section className="space-y-6"> | |
| {sections.map((section, sectionIndex) => { | |
| const isCollapsed = collapsedSections[section.id] ?? false; | |
| const pageCount = section.pages?.length ?? 0; | |
| const start = | |
| sections | |
| .slice(0, sectionIndex) | |
| .reduce((sum, item) => sum + (item.pages?.length ?? 0), 0) + 1; | |
| const end = Math.max(start, start + pageCount - 1); | |
| return ( | |
| <div key={section.id} className="rounded-lg border border-gray-200 bg-white p-4"> | |
| <div className="flex flex-wrap items-center justify-between gap-3 mb-4"> | |
| <div> | |
| <div className="text-xs font-semibold text-gray-500"> | |
| Section {sectionIndex + 1} | |
| </div> | |
| <input | |
| type="text" | |
| value={section.title ?? ""} | |
| onChange={(event) => | |
| setSections((prev) => | |
| prev.map((item, idx) => | |
| idx === sectionIndex | |
| ? { ...item, title: event.target.value } | |
| : item, | |
| ), | |
| ) | |
| } | |
| placeholder={`Section ${sectionIndex + 1}`} | |
| className="mt-1 w-60 rounded-md border border-gray-200 px-2 py-1 text-sm font-semibold text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-200" | |
| /> | |
| <div className="text-xs text-gray-500"> | |
| {pageCount} page{pageCount === 1 ? "" : "s"} - Pages {start} | |
| {pageCount > 1 ? `-${end}` : ""} | |
| </div> | |
| </div> | |
| <div className="flex items-center gap-2"> | |
| <button | |
| type="button" | |
| onClick={() => toggleSection(section.id)} | |
| className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs font-semibold text-gray-800 hover:bg-gray-50 transition" | |
| > | |
| {isCollapsed ? ( | |
| <> | |
| <ChevronDown className="h-4 w-4" /> | |
| Expand | |
| </> | |
| ) : ( | |
| <> | |
| <ChevronUp className="h-4 w-4" /> | |
| Collapse | |
| </> | |
| )} | |
| </button> | |
| <select | |
| value={newPageTemplateBySection[section.id] ?? "repex:blank"} | |
| onChange={(event) => | |
| setNewPageTemplateBySection((prev) => ({ | |
| ...prev, | |
| [section.id]: event.target.value, | |
| })) | |
| } | |
| className="rounded-lg border border-gray-200 bg-white px-2 py-2 text-xs font-semibold text-gray-800 focus:outline-none focus:ring-2 focus:ring-blue-200" | |
| title="Template used when adding a page to this section" | |
| > | |
| {pageTemplates.map((template) => ( | |
| <option key={`section-${section.id}-template-${template.id}`} value={template.id}> | |
| {template.name} | |
| </option> | |
| ))} | |
| </select> | |
| <button | |
| type="button" | |
| onClick={() => handleAddPage(sectionIndex)} | |
| disabled={!canModify} | |
| className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs font-semibold text-gray-800 hover:bg-gray-50 transition disabled:opacity-50 disabled:cursor-not-allowed" | |
| > | |
| <Plus className="h-4 w-4" /> | |
| Add page | |
| </button> | |
| <Link | |
| to={`/edit-layouts/templates${sessionQuery}`} | |
| onClick={(event) => { | |
| event.preventDefault(); | |
| requestNavigation(`/edit-layouts/templates${sessionQuery}`); | |
| }} | |
| className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs font-semibold text-gray-800 hover:bg-gray-50 transition" | |
| > | |
| Manage templates | |
| </Link> | |
| </div> | |
| </div> | |
| {!isCollapsed ? ( | |
| <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> | |
| {(section.pages ?? []).map((page, pageIndex) => { | |
| const currentTemplateId = inferPageTemplateId(page); | |
| const currentTemplate = findTemplate(currentTemplateId); | |
| return ( | |
| <div | |
| key={`${section.id}-page-${pageIndex}`} | |
| className="rounded-lg border border-gray-200 bg-white p-3" | |
| > | |
| <div className="flex items-center justify-between mb-2"> | |
| <div> | |
| <div className="text-sm font-semibold text-gray-900"> | |
| Page {pageIndex + 1} | |
| </div> | |
| <div className="text-xs text-gray-500"> | |
| {page.items?.length ?? 0} items - Global page {start + pageIndex} of {totalPages} | |
| </div> | |
| </div> | |
| <div className="flex items-center gap-2"> | |
| <button | |
| type="button" | |
| onClick={() => handleMovePage(sectionIndex, pageIndex, -1)} | |
| disabled={pageIndex === 0 || !canModify} | |
| className="inline-flex items-center justify-center rounded-lg border border-gray-200 bg-white p-1.5 text-gray-600 hover:bg-gray-50 transition disabled:opacity-50 disabled:cursor-not-allowed" | |
| aria-label="Move page up" | |
| > | |
| <ChevronUp className="h-3.5 w-3.5" /> | |
| </button> | |
| <button | |
| type="button" | |
| onClick={() => handleMovePage(sectionIndex, pageIndex, 1)} | |
| disabled={pageIndex === (section.pages?.length ?? 1) - 1 || !canModify} | |
| className="inline-flex items-center justify-center rounded-lg border border-gray-200 bg-white p-1.5 text-gray-600 hover:bg-gray-50 transition disabled:opacity-50 disabled:cursor-not-allowed" | |
| aria-label="Move page down" | |
| > | |
| <ChevronDown className="h-3.5 w-3.5" /> | |
| </button> | |
| <button | |
| type="button" | |
| onClick={() => handleRemovePage(sectionIndex, pageIndex)} | |
| disabled={(section.pages?.length ?? 1) <= 1 || !canModify} | |
| className="inline-flex items-center gap-1 rounded-lg border border-red-200 bg-red-50 px-2.5 py-1.5 text-xs font-semibold text-red-700 hover:bg-red-100 transition disabled:opacity-50 disabled:cursor-not-allowed" | |
| > | |
| <Trash2 className="h-3.5 w-3.5" /> | |
| Remove | |
| </button> | |
| </div> | |
| </div> | |
| <div className="mb-2 rounded-md border border-gray-200 bg-gray-50 p-2"> | |
| <label className="block text-[11px] font-semibold uppercase tracking-wide text-gray-500"> | |
| Page Template | |
| </label> | |
| <select | |
| value={currentTemplateId} | |
| onChange={(event) => | |
| handleChangePageTemplate( | |
| sectionIndex, | |
| pageIndex, | |
| event.target.value, | |
| ) | |
| } | |
| className="mt-1 w-full rounded-md border border-gray-200 bg-white px-2 py-1 text-xs font-semibold text-gray-800 focus:outline-none focus:ring-2 focus:ring-blue-200" | |
| > | |
| {pageTemplates.map((template) => ( | |
| <option | |
| key={`page-${section.id}-${pageIndex}-${template.id}`} | |
| value={template.id} | |
| > | |
| {template.name} | |
| </option> | |
| ))} | |
| </select> | |
| <div className="mt-1 text-[11px] text-gray-500"> | |
| {currentTemplate.description || "No description."} | |
| </div> | |
| </div> | |
| <div | |
| className="mx-auto rounded-lg border border-gray-200 bg-white" | |
| style={{ width: previewWidth }} | |
| > | |
| <ReportPageCanvas | |
| session={session} | |
| page={page} | |
| pageIndex={pageIndex} | |
| pageCount={totalPages} | |
| scale={previewScale} | |
| template={page?.template} | |
| sectionLabel={ | |
| section.title | |
| ? `Section ${sectionIndex + 1} - ${section.title}` | |
| : `Section ${sectionIndex + 1}` | |
| } | |
| adaptive | |
| /> | |
| </div> | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| ) : ( | |
| <div className="rounded-md border border-dashed border-gray-200 bg-gray-50 px-3 py-2 text-xs text-gray-500"> | |
| Section collapsed. Expand to edit pages and apply layout presets. | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| })} | |
| </section> | |
| <PageFooter note="Tip: reorder pages here and remove empty pages before export." /> | |
| <SaveBeforeLeaveDialog | |
| open={Boolean(pendingNavigationTarget)} | |
| onCancel={() => setPendingNavigationTarget(null)} | |
| onProceed={leaveWithoutWaiting} | |
| onWait={waitForSaveAndContinue} | |
| /> | |
| </PageShell> | |
| ); | |
| } | |