Spaces:
Sleeping
Sleeping
| import { useEffect, useMemo, useRef, useState } from "react"; | |
| import { Link, useNavigate, useSearchParams } from "react-router-dom"; | |
| import { ArrowLeft, Download, Edit3, Grid, Layout, Table, Image } from "react-feather"; | |
| import { API_BASE, postForm, putJson, request } from "../lib/api"; | |
| import { formatDocNumber } from "../lib/report"; | |
| import { | |
| ensureSections, | |
| flattenSections, | |
| insertPage, | |
| removePage, | |
| replacePage, | |
| } from "../lib/sections"; | |
| import { buildSessionQuery, getSessionId, setStoredSessionId } from "../lib/session"; | |
| import type { Heading, JobsheetSection, Session, TemplateFields } from "../types/session"; | |
| import { PageFooter } from "../components/PageFooter"; | |
| import { PageHeader } from "../components/PageHeader"; | |
| import { PageShell } from "../components/PageShell"; | |
| import { InfoMenu } from "../components/InfoMenu"; | |
| import { LoadingBar } from "../components/LoadingBar"; | |
| import { SaveBeforeLeaveDialog } from "../components/SaveBeforeLeaveDialog"; | |
| type FieldDef = { | |
| key: keyof TemplateFields; | |
| label: string; | |
| multiline?: boolean; | |
| }; | |
| const normalizeHeadings = (raw?: Heading[] | Record<string, string>): Heading[] => { | |
| if (Array.isArray(raw)) { | |
| return raw | |
| .filter(Boolean) | |
| .map((heading) => ({ | |
| number: String(heading.number ?? "").trim(), | |
| name: String(heading.name ?? "").trim(), | |
| })); | |
| } | |
| if (raw && typeof raw === "object") { | |
| return Object.entries(raw).map(([number, name]) => ({ | |
| number: String(number).trim(), | |
| name: String(name ?? "").trim(), | |
| })); | |
| } | |
| return []; | |
| }; | |
| const GENERAL_FIELDS: FieldDef[] = [ | |
| { key: "inspection_date", label: "Inspection Date" }, | |
| { key: "inspector", label: "Inspector" }, | |
| { key: "document_no", label: "Document No" }, | |
| { key: "company_logo", label: "Company Logo" }, | |
| ]; | |
| const ITEM_FIELDS: FieldDef[] = [ | |
| { key: "area", label: "Area" }, | |
| { key: "reference", label: "Reference" }, | |
| { key: "functional_location", label: "Location" }, | |
| { key: "item_description", label: "Item Description", multiline: true }, | |
| { key: "category", label: "Category" }, | |
| { key: "priority", label: "Priority" }, | |
| { key: "required_action", label: "Required Action", multiline: true }, | |
| { key: "figure_caption", label: "Figure Caption" }, | |
| ]; | |
| export default function InputDataPage() { | |
| 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 [isUploading, setIsUploading] = useState(false); | |
| const [copySourceIndex, setCopySourceIndex] = useState(0); | |
| const [copyTargets, setCopyTargets] = useState(""); | |
| const [addSectionId, setAddSectionId] = useState<string>(""); | |
| const [sectionsCollapsed, setSectionsCollapsed] = useState(true); | |
| const [headings, setHeadings] = useState<Heading[]>([]); | |
| const [showGeneralColumns, setShowGeneralColumns] = useState(false); | |
| const [generalDirty, setGeneralDirty] = useState(false); | |
| const [generalTemplate, setGeneralTemplate] = useState<TemplateFields>({}); | |
| const [uploadProgress, setUploadProgress] = useState<number | null>(null); | |
| const [pendingNavigationTarget, setPendingNavigationTarget] = useState< | |
| string | null | |
| >(null); | |
| const [photoSelections, setPhotoSelections] = useState<Record<number, string>>( | |
| {}, | |
| ); | |
| const saveTimerRef = useRef<number | null>(null); | |
| const generalApplyTimerRef = useRef<number | null>(null); | |
| const headingsSaveTimerRef = useRef<number | null>(null); | |
| const lastSavedHeadingsRef = useRef<string>(JSON.stringify([])); | |
| const headingsLoadedRef = useRef(false); | |
| const lastSavedRef = useRef<string>(""); | |
| const pendingSaveRef = useRef<string>(""); | |
| const savePromiseRef = useRef<Promise<void> | null>(null); | |
| const excelInputRef = useRef<HTMLInputElement | null>(null); | |
| const jsonInputRef = useRef<HTMLInputElement | null>(null); | |
| const uploadInputRefs = useRef<Record<number, HTMLInputElement | null>>({}); | |
| const uploadTimerRef = useRef<number | null>(null); | |
| function clearUploadTimer() { | |
| if (uploadTimerRef.current !== null) { | |
| window.clearInterval(uploadTimerRef.current); | |
| uploadTimerRef.current = null; | |
| } | |
| } | |
| function startUploadProgress() { | |
| clearUploadTimer(); | |
| setUploadProgress(6); | |
| uploadTimerRef.current = window.setInterval(() => { | |
| setUploadProgress((prev) => { | |
| const value = typeof prev === "number" ? prev : 0; | |
| if (value >= 92) return value; | |
| return Math.min(92, value + Math.max(1, (92 - value) * 0.08)); | |
| }); | |
| }, 350); | |
| } | |
| useEffect(() => { | |
| if (!sessionId) { | |
| setStatus("No active session found. Return to upload to continue."); | |
| return; | |
| } | |
| setStoredSessionId(sessionId); | |
| async function load() { | |
| try { | |
| const data = await request<Session>(`/sessions/${sessionId}`); | |
| setSession(data); | |
| const sectionResp = await request<{ sections: JobsheetSection[] }>( | |
| `/sessions/${sessionId}/sections`, | |
| ); | |
| const normalized = ensureSections(sectionResp.sections); | |
| setSections(normalized); | |
| const initialHeadings = normalizeHeadings(data.headings); | |
| setHeadings(initialHeadings); | |
| headingsLoadedRef.current = true; | |
| lastSavedHeadingsRef.current = JSON.stringify(initialHeadings); | |
| 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]); | |
| useEffect( | |
| () => () => { | |
| clearUploadTimer(); | |
| }, | |
| [], | |
| ); | |
| async function refreshSession() { | |
| if (!sessionId) return; | |
| const data = await request<Session>(`/sessions/${sessionId}`); | |
| setSession(data); | |
| const sectionResp = await request<{ sections: JobsheetSection[] }>( | |
| `/sessions/${sessionId}/sections`, | |
| ); | |
| const normalized = ensureSections(sectionResp.sections); | |
| setSections(normalized); | |
| const nextHeadings = normalizeHeadings(data.headings); | |
| setHeadings(nextHeadings); | |
| headingsLoadedRef.current = true; | |
| lastSavedHeadingsRef.current = JSON.stringify(nextHeadings); | |
| lastSavedRef.current = JSON.stringify(normalized); | |
| pendingSaveRef.current = lastSavedRef.current; | |
| setSaveState("saved"); | |
| } | |
| const flatPages = useMemo( | |
| () => flattenSections(ensureSections(sections)), | |
| [sections], | |
| ); | |
| const totalPages = useMemo(() => { | |
| if (flatPages.length > 0) return flatPages.length; | |
| return Math.max(1, session?.page_count ?? 0); | |
| }, [flatPages.length, session?.page_count]); | |
| useEffect(() => { | |
| if (copySourceIndex >= flatPages.length) { | |
| setCopySourceIndex(Math.max(0, flatPages.length - 1)); | |
| } | |
| }, [copySourceIndex, flatPages.length]); | |
| useEffect(() => { | |
| if (!addSectionId && sections.length > 0) { | |
| setAddSectionId(sections[0].id); | |
| } | |
| }, [addSectionId, sections]); | |
| useEffect(() => { | |
| if (generalDirty) return; | |
| const source = flatPages[0]?.page?.template ?? {}; | |
| const next: TemplateFields = {}; | |
| GENERAL_FIELDS.forEach((field) => { | |
| const value = source[field.key] ?? getFallbackValue(field.key); | |
| if (value !== undefined) { | |
| next[field.key] = value; | |
| } | |
| }); | |
| setGeneralTemplate(next); | |
| }, [generalDirty, flatPages, session]); | |
| function updateField( | |
| entryIndex: number, | |
| key: keyof TemplateFields, | |
| value: string, | |
| ) { | |
| const entry = flatPages[entryIndex]; | |
| if (!entry) return; | |
| setSections((prev) => { | |
| const template = { ...(entry.page.template ?? {}) }; | |
| template[key] = value; | |
| const nextPage = { ...entry.page, template }; | |
| return replacePage(prev, entry.sectionIndex, entry.pageIndex, nextPage); | |
| }); | |
| } | |
| function updateGeneralField(key: keyof TemplateFields, value: string) { | |
| setGeneralTemplate((prev) => ({ ...prev, [key]: value })); | |
| setGeneralDirty(true); | |
| } | |
| function updateHeadingField(index: number, key: keyof Heading, value: string) { | |
| setHeadings((prev) => | |
| prev.map((heading, idx) => | |
| idx === index ? { ...heading, [key]: value } : heading, | |
| ), | |
| ); | |
| } | |
| function addHeadingRow() { | |
| setHeadings((prev) => [...prev, { number: "", name: "" }]); | |
| } | |
| function removeHeadingRow(index: number) { | |
| setHeadings((prev) => prev.filter((_, idx) => idx !== index)); | |
| } | |
| function applyGeneralToSections( | |
| source: JobsheetSection[], | |
| template: TemplateFields, | |
| ): JobsheetSection[] { | |
| return source.map((section) => ({ | |
| ...section, | |
| pages: (section.pages ?? []).map((page) => { | |
| const nextTemplate = { ...(page.template ?? {}) }; | |
| GENERAL_FIELDS.forEach((field) => { | |
| const value = template[field.key]; | |
| if (value !== undefined) { | |
| nextTemplate[field.key] = value; | |
| } | |
| }); | |
| return { ...page, template: nextTemplate }; | |
| }), | |
| })); | |
| } | |
| function applyRowToAll(pageIndex: number) { | |
| const entry = flatPages[pageIndex]; | |
| if (!entry) return; | |
| const source = entry.page.template ?? {}; | |
| setSections((prev) => | |
| prev.map((section) => ({ | |
| ...section, | |
| pages: (section.pages ?? []).map((page) => ({ | |
| ...page, | |
| template: { ...source }, | |
| })), | |
| })), | |
| ); | |
| } | |
| function applyGeneralToAll(silent = false) { | |
| if (!flatPages.length) return; | |
| setSections((prev) => applyGeneralToSections(prev, generalTemplate)); | |
| setGeneralDirty(false); | |
| if (!silent) { | |
| setStatus("Applied general info to all pages."); | |
| } | |
| } | |
| function insertPageAt(index: number, templateSource?: TemplateFields) { | |
| const atEnd = index >= flatPages.length; | |
| const entry = flatPages[index] ?? flatPages[flatPages.length - 1]; | |
| if (!entry) return; | |
| setSections((prev) => { | |
| const fallbackTemplate = | |
| templateSource ?? | |
| entry.page.template ?? | |
| {}; | |
| const pageIndex = atEnd ? entry.pageIndex + 1 : entry.pageIndex; | |
| return insertPage(prev, entry.sectionIndex, pageIndex, { | |
| items: [], | |
| template: { ...fallbackTemplate }, | |
| }); | |
| }); | |
| } | |
| function addSection() { | |
| const baseTemplate = { ...generalTemplate }; | |
| setSections((prev) => [ | |
| ...prev, | |
| { | |
| id: crypto.randomUUID(), | |
| title: `Section ${prev.length + 1}`, | |
| pages: [{ items: [], template: baseTemplate }], | |
| }, | |
| ]); | |
| } | |
| function updateSectionTitle(sectionId: string, value: string) { | |
| setSections((prev) => | |
| prev.map((section) => | |
| section.id === sectionId ? { ...section, title: value } : section, | |
| ), | |
| ); | |
| } | |
| function insertPageIntoSection(sectionId: string, templateSource?: TemplateFields) { | |
| const sectionIndex = sections.findIndex((section) => section.id === sectionId); | |
| if (sectionIndex === -1) return; | |
| const section = sections[sectionIndex]; | |
| const fallbackTemplate = | |
| templateSource ?? | |
| section.pages?.[section.pages.length - 1]?.template ?? | |
| generalTemplate ?? | |
| {}; | |
| setSections((prev) => | |
| insertPage(prev, sectionIndex, section.pages?.length ?? 0, { | |
| items: [], | |
| template: { ...fallbackTemplate }, | |
| }), | |
| ); | |
| } | |
| function removePageAt(index: number) { | |
| const entry = flatPages[index]; | |
| if (!entry) return; | |
| setSections((prev) => | |
| removePage(prev, entry.sectionIndex, entry.pageIndex), | |
| ); | |
| } | |
| function updatePhotoSelection(pageIndex: number, value: string) { | |
| setPhotoSelections((prev) => ({ ...prev, [pageIndex]: value })); | |
| } | |
| function updatePagePhotos(pageIndex: number, nextIds: string[]) { | |
| const entry = flatPages[pageIndex]; | |
| if (!entry) return; | |
| setSections((prev) => { | |
| const nextPage = { ...entry.page, photo_ids: nextIds }; | |
| return replacePage(prev, entry.sectionIndex, entry.pageIndex, nextPage); | |
| }); | |
| } | |
| function setPhotoOrderLocked(pageIndex: number, locked: boolean) { | |
| const entry = flatPages[pageIndex]; | |
| if (!entry) return; | |
| setSections((prev) => { | |
| const nextPage = { ...entry.page, photo_order_locked: locked }; | |
| return replacePage(prev, entry.sectionIndex, entry.pageIndex, nextPage); | |
| }); | |
| } | |
| function movePhoto(pageIndex: number, from: number, to: number) { | |
| const entry = flatPages[pageIndex]; | |
| if (!entry) return; | |
| setSections((prev) => { | |
| const ids = [...(entry.page.photo_ids ?? [])]; | |
| if (from < 0 || from >= ids.length || to < 0 || to >= ids.length) { | |
| return prev; | |
| } | |
| const [moved] = ids.splice(from, 1); | |
| ids.splice(to, 0, moved); | |
| const nextPage = { ...entry.page, photo_ids: ids }; | |
| return replacePage(prev, entry.sectionIndex, entry.pageIndex, nextPage); | |
| }); | |
| } | |
| function removePhoto(pageIndex: number, index: number) { | |
| const entry = flatPages[pageIndex]; | |
| if (!entry) return; | |
| setSections((prev) => { | |
| const ids = [...(entry.page.photo_ids ?? [])]; | |
| ids.splice(index, 1); | |
| const nextPage = { ...entry.page, photo_ids: ids }; | |
| return replacePage(prev, entry.sectionIndex, entry.pageIndex, nextPage); | |
| }); | |
| } | |
| function addPhotoToPage(pageIndex: number, photoId: string) { | |
| if (!photoId) return; | |
| const entry = flatPages[pageIndex]; | |
| if (!entry) return; | |
| setSections((prev) => { | |
| const ids = [...(entry.page.photo_ids ?? [])]; | |
| if (!ids.includes(photoId)) ids.push(photoId); | |
| const nextPage = { ...entry.page, photo_ids: ids }; | |
| return replacePage(prev, entry.sectionIndex, entry.pageIndex, nextPage); | |
| }); | |
| } | |
| function parseTargetPages(value: string, max: number): number[] { | |
| const results = new Set<number>(); | |
| const parts = value | |
| .split(",") | |
| .map((part) => part.trim()) | |
| .filter(Boolean); | |
| parts.forEach((part) => { | |
| if (part.includes("-")) { | |
| const [startRaw, endRaw] = part.split("-").map((chunk) => chunk.trim()); | |
| const start = Number.parseInt(startRaw, 10); | |
| const end = Number.parseInt(endRaw, 10); | |
| if (Number.isNaN(start) || Number.isNaN(end)) return; | |
| const min = Math.min(start, end); | |
| const maxRange = Math.max(start, end); | |
| for (let idx = min; idx <= maxRange; idx += 1) { | |
| if (idx >= 1 && idx <= max) results.add(idx - 1); | |
| } | |
| } else { | |
| const pageNum = Number.parseInt(part, 10); | |
| if (!Number.isNaN(pageNum) && pageNum >= 1 && pageNum <= max) { | |
| results.add(pageNum - 1); | |
| } | |
| } | |
| }); | |
| return Array.from(results).sort((a, b) => a - b); | |
| } | |
| function copyPageToTargets() { | |
| if (!flatPages.length) return; | |
| const targets = parseTargetPages(copyTargets, flatPages.length).filter( | |
| (idx) => idx !== copySourceIndex, | |
| ); | |
| if (!targets.length) { | |
| setStatus("No valid target pages selected for copy."); | |
| return; | |
| } | |
| const sourceTemplate = flatPages[copySourceIndex]?.page?.template ?? {}; | |
| setSections((prev) => | |
| prev.map((section, sIdx) => ({ | |
| ...section, | |
| pages: (section.pages ?? []).map((page, pIdx) => { | |
| const flatIndex = | |
| flattenSections(prev).find( | |
| (entry) => | |
| entry.sectionIndex === sIdx && entry.pageIndex === pIdx, | |
| )?.flatIndex ?? -1; | |
| if (!targets.includes(flatIndex)) return page; | |
| return { ...page, template: { ...sourceTemplate } }; | |
| }), | |
| })), | |
| ); | |
| setStatus( | |
| `Copied page ${copySourceIndex + 1} to ${targets | |
| .map((idx) => idx + 1) | |
| .join(", ")}.`, | |
| ); | |
| } | |
| function getFallbackValue(field: keyof TemplateFields): string { | |
| if (!session) return ""; | |
| switch (field) { | |
| case "inspection_date": | |
| return session.inspection_date || ""; | |
| case "document_no": | |
| return session.document_no || formatDocNumber(session); | |
| default: | |
| return ""; | |
| } | |
| } | |
| async function saveAll( | |
| silent = false, | |
| overrideSections?: JobsheetSection[], | |
| ) { | |
| if (!sessionId) return; | |
| if (savePromiseRef.current) { | |
| await savePromiseRef.current; | |
| } | |
| const toSave = overrideSections ?? sections; | |
| const snapshot = JSON.stringify(toSave); | |
| if (!overrideSections && snapshot === lastSavedRef.current) { | |
| if (!isSaving) setSaveState("saved"); | |
| return; | |
| } | |
| const promise = (async () => { | |
| setIsSaving(true); | |
| setSaveState("saving"); | |
| if (!silent) { | |
| setStatus("Saving input data..."); | |
| } | |
| try { | |
| const resp = await putJson<{ sections: JobsheetSection[] }>( | |
| `/sessions/${sessionId}/sections`, | |
| { sections: toSave }, | |
| ); | |
| const updated = ensureSections(resp.sections ?? toSave); | |
| setSections(updated); | |
| lastSavedRef.current = JSON.stringify(updated); | |
| pendingSaveRef.current = lastSavedRef.current; | |
| setSaveState("saved"); | |
| if (!silent) { | |
| setStatus("Input data saved."); | |
| } else { | |
| setStatus("All changes saved."); | |
| } | |
| } catch (err) { | |
| const message = | |
| err instanceof Error ? err.message : "Failed to save input data."; | |
| 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 triggerAutoSave() { | |
| if (!sessionId || savePromiseRef.current) return; | |
| const snapshot = pendingSaveRef.current; | |
| if (!snapshot || snapshot === lastSavedRef.current) return; | |
| await saveAll(true); | |
| } | |
| useEffect(() => { | |
| if (!sessionId) return; | |
| const snapshot = JSON.stringify(sections); | |
| if (snapshot === lastSavedRef.current) { | |
| if (!generalDirty && !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, generalDirty, isSaving]); | |
| useEffect(() => { | |
| if (!generalDirty) return; | |
| if (!isSaving) { | |
| setSaveState("pending"); | |
| } | |
| if (generalApplyTimerRef.current) { | |
| window.clearTimeout(generalApplyTimerRef.current); | |
| } | |
| generalApplyTimerRef.current = window.setTimeout(() => { | |
| applyGeneralToAll(true); | |
| }, 800); | |
| }, [generalDirty, generalTemplate, isSaving]); | |
| useEffect(() => { | |
| if (!sessionId) return; | |
| if (!headingsLoadedRef.current) return; | |
| const snapshot = JSON.stringify(headings); | |
| if (snapshot === lastSavedHeadingsRef.current) return; | |
| if (headingsSaveTimerRef.current) { | |
| window.clearTimeout(headingsSaveTimerRef.current); | |
| } | |
| headingsSaveTimerRef.current = window.setTimeout(() => { | |
| void saveHeadings(true); | |
| }, 800); | |
| }, [headings, sessionId]); | |
| async function saveHeadings(silent = false) { | |
| if (!sessionId) return; | |
| const snapshot = JSON.stringify(headings); | |
| if (snapshot === lastSavedHeadingsRef.current) return; | |
| try { | |
| if (!silent) { | |
| setStatus("Saving headings..."); | |
| } | |
| const resp = await putJson<{ headings: Heading[] }>( | |
| `/sessions/${sessionId}/headings`, | |
| { headings }, | |
| ); | |
| const normalized = resp.headings ?? headings; | |
| setHeadings(normalized); | |
| lastSavedHeadingsRef.current = JSON.stringify(normalized); | |
| if (!silent) { | |
| setStatus("Headings saved."); | |
| } | |
| } catch (err) { | |
| const message = | |
| err instanceof Error ? err.message : "Failed to save headings."; | |
| setStatus(message); | |
| } | |
| } | |
| async function uploadDataFile(file: File) { | |
| if (!sessionId) return; | |
| setIsUploading(true); | |
| setStatus("Uploading data file..."); | |
| startUploadProgress(); | |
| try { | |
| const form = new FormData(); | |
| form.append("file", file); | |
| await postForm<Session>(`/sessions/${sessionId}/data-files`, form); | |
| await refreshSession(); | |
| setStatus("Data file imported."); | |
| setGeneralDirty(false); | |
| clearUploadTimer(); | |
| setUploadProgress(100); | |
| } catch (err) { | |
| const message = | |
| err instanceof Error ? err.message : "Failed to import data file."; | |
| setStatus(message); | |
| clearUploadTimer(); | |
| setUploadProgress(null); | |
| } finally { | |
| setIsUploading(false); | |
| window.setTimeout(() => { | |
| setUploadProgress(null); | |
| }, 500); | |
| } | |
| } | |
| async function uploadJsonFile(file: File) { | |
| if (!sessionId) return; | |
| setIsUploading(true); | |
| setStatus("Uploading JSON package..."); | |
| startUploadProgress(); | |
| try { | |
| const form = new FormData(); | |
| form.append("file", file); | |
| await postForm<Session>(`/sessions/${sessionId}/import-json`, form); | |
| await refreshSession(); | |
| setStatus("JSON package imported."); | |
| setGeneralDirty(false); | |
| clearUploadTimer(); | |
| setUploadProgress(100); | |
| } catch (err) { | |
| const message = | |
| err instanceof Error ? err.message : "Failed to import JSON package."; | |
| setStatus(message); | |
| clearUploadTimer(); | |
| setUploadProgress(null); | |
| } finally { | |
| setIsUploading(false); | |
| window.setTimeout(() => { | |
| setUploadProgress(null); | |
| }, 500); | |
| } | |
| } | |
| async function uploadPhotoForPage(pageIndex: number, file: File) { | |
| if (!sessionId) return; | |
| setIsUploading(true); | |
| setStatus(`Uploading image ${file.name}...`); | |
| startUploadProgress(); | |
| const existing = new Set( | |
| (session?.uploads?.photos ?? []).map((photo) => photo.id), | |
| ); | |
| try { | |
| const form = new FormData(); | |
| form.append("file", file); | |
| const updated = await postForm<Session>( | |
| `/sessions/${sessionId}/uploads`, | |
| form, | |
| ); | |
| setSession(updated); | |
| const newPhoto = | |
| (updated.uploads?.photos ?? []).find((photo) => !existing.has(photo.id)) ?? | |
| (updated.uploads?.photos ?? []).find((photo) => photo.name === file.name); | |
| if (newPhoto) { | |
| addPhotoToPage(pageIndex, newPhoto.id); | |
| setStatus(`Uploaded ${newPhoto.name}.`); | |
| } else { | |
| setStatus("Uploaded image. Refresh to see new file."); | |
| } | |
| clearUploadTimer(); | |
| setUploadProgress(100); | |
| } catch (err) { | |
| const message = | |
| err instanceof Error ? err.message : "Failed to upload image."; | |
| setStatus(message); | |
| clearUploadTimer(); | |
| setUploadProgress(null); | |
| } finally { | |
| setIsUploading(false); | |
| window.setTimeout(() => { | |
| setUploadProgress(null); | |
| }, 500); | |
| } | |
| } | |
| async function saveAndNavigate(target: string) { | |
| if (!sessionId) { | |
| navigate(target); | |
| return; | |
| } | |
| if (generalApplyTimerRef.current) { | |
| window.clearTimeout(generalApplyTimerRef.current); | |
| generalApplyTimerRef.current = null; | |
| } | |
| let overrideSections: JobsheetSection[] | undefined; | |
| if (generalDirty) { | |
| overrideSections = applyGeneralToSections(sections, generalTemplate); | |
| setSections(overrideSections); | |
| setGeneralDirty(false); | |
| } | |
| if (saveTimerRef.current) { | |
| window.clearTimeout(saveTimerRef.current); | |
| saveTimerRef.current = null; | |
| } | |
| const snapshot = JSON.stringify(overrideSections ?? sections); | |
| pendingSaveRef.current = snapshot; | |
| if (snapshot !== lastSavedRef.current || savePromiseRef.current) { | |
| await saveAll(true, overrideSections); | |
| } | |
| 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 className="max-w-6xl"> | |
| <PageHeader | |
| title="RepEx - Report Express" | |
| subtitle="Input Data" | |
| 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"> | |
| <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"> | |
| <Table className="h-4 w-4" /> | |
| Input Data | |
| </span> | |
| <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> | |
| <Link | |
| to={`/edit-layouts${sessionQuery}`} | |
| onClick={(event) => { | |
| event.preventDefault(); | |
| requestNavigation(`/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> | |
| <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-4 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">Job Sheet Data</h2> | |
| <p className="text-sm text-gray-600"> | |
| Update job sheet fields per page. Use "Apply row to all" to copy a | |
| page's fields across every job sheet. | |
| </p> | |
| </div> | |
| <div className="flex flex-wrap gap-2"> | |
| <div className="flex items-center gap-2"> | |
| <select | |
| className="rounded-md border border-gray-200 px-2 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-200" | |
| value={addSectionId} | |
| onChange={(event) => setAddSectionId(event.target.value)} | |
| disabled={!sections.length} | |
| > | |
| {sections.map((section, idx) => ( | |
| <option key={section.id} value={section.id}> | |
| {section.title || `Section ${idx + 1}`} | |
| </option> | |
| ))} | |
| </select> | |
| <button | |
| type="button" | |
| onClick={() => insertPageIntoSection(addSectionId, generalTemplate)} | |
| disabled={!sessionId || !addSectionId} | |
| 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" | |
| > | |
| Add blank page | |
| </button> | |
| </div> | |
| <button | |
| type="button" | |
| onClick={addSection} | |
| disabled={!sessionId} | |
| 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" | |
| > | |
| Add section | |
| </button> | |
| </div> | |
| </div> | |
| {status ? <p className="text-sm text-gray-600 mt-3">{status}</p> : null} | |
| <LoadingBar | |
| active={isSaving} | |
| progress={null} | |
| label="Saving changes" | |
| className="mt-3" | |
| /> | |
| <LoadingBar | |
| active={isUploading} | |
| progress={uploadProgress} | |
| label={status || "Uploading files"} | |
| className="mt-3" | |
| /> | |
| </section> | |
| <section className="mb-4 rounded-lg border border-gray-200 bg-white p-4"> | |
| <div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4"> | |
| <div> | |
| <h3 className="text-base font-semibold text-gray-900">Copy page to targets</h3> | |
| <p className="text-sm text-gray-600"> | |
| Choose a source page and the pages to overwrite (e.g. 2,4-6). | |
| </p> | |
| </div> | |
| <div className="flex flex-wrap items-end gap-2"> | |
| <label className="text-xs text-gray-600"> | |
| Source page | |
| <select | |
| className="mt-1 w-36 rounded-md border border-gray-200 px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-blue-200" | |
| value={copySourceIndex} | |
| onChange={(event) => setCopySourceIndex(Number(event.target.value))} | |
| > | |
| {flatPages.map((_, idx) => ( | |
| <option key={`copy-source-${idx}`} value={idx}> | |
| Page {idx + 1} | |
| </option> | |
| ))} | |
| </select> | |
| </label> | |
| <label className="text-xs text-gray-600"> | |
| Target pages | |
| <input | |
| type="text" | |
| placeholder="e.g. 2,4-6" | |
| className="mt-1 w-44 rounded-md border border-gray-200 px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-blue-200" | |
| value={copyTargets} | |
| onChange={(event) => setCopyTargets(event.target.value)} | |
| /> | |
| </label> | |
| <button | |
| type="button" | |
| onClick={copyPageToTargets} | |
| disabled={!flatPages.length} | |
| className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition disabled:opacity-50 disabled:cursor-not-allowed" | |
| > | |
| Copy page data | |
| </button> | |
| </div> | |
| </div> | |
| </section> | |
| <section className="mb-4 rounded-lg border border-gray-200 bg-white p-4"> | |
| <div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-3"> | |
| <div> | |
| <h3 className="text-base font-semibold text-gray-900"> | |
| Import / Export data files | |
| </h3> | |
| <p className="text-sm text-gray-600"> | |
| Upload an Excel/CSV data file or a JSON package to populate job sheets. | |
| </p> | |
| </div> | |
| <div className="flex flex-wrap gap-2"> | |
| <button | |
| type="button" | |
| onClick={() => excelInputRef.current?.click()} | |
| disabled={!sessionId || isUploading} | |
| 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" | |
| > | |
| Upload Excel/CSV | |
| </button> | |
| <button | |
| type="button" | |
| onClick={() => jsonInputRef.current?.click()} | |
| disabled={!sessionId || isUploading} | |
| 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" | |
| > | |
| Upload JSON | |
| </button> | |
| <a | |
| href={ | |
| sessionId | |
| ? `${API_BASE}/sessions/${sessionId}/export.xlsx` | |
| : "#" | |
| } | |
| 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 Excel | |
| </a> | |
| <a | |
| href={ | |
| sessionId | |
| ? `${API_BASE}/sessions/${sessionId}/export` | |
| : "#" | |
| } | |
| 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 JSON | |
| </a> | |
| </div> | |
| </div> | |
| <input | |
| ref={excelInputRef} | |
| type="file" | |
| accept=".xlsx,.xls,.csv" | |
| className="hidden" | |
| onChange={(event) => { | |
| const file = event.target.files?.[0]; | |
| if (file) uploadDataFile(file); | |
| event.target.value = ""; | |
| }} | |
| /> | |
| <input | |
| ref={jsonInputRef} | |
| type="file" | |
| accept=".json,application/json" | |
| className="hidden" | |
| onChange={(event) => { | |
| const file = event.target.files?.[0]; | |
| if (file) uploadJsonFile(file); | |
| event.target.value = ""; | |
| }} | |
| /> | |
| </section> | |
| <section className="mb-4 rounded-lg border border-gray-200 bg-white p-4"> | |
| <div className="grid grid-cols-1 lg:grid-cols-[minmax(0,1fr),320px] gap-4 items-start"> | |
| <div className="min-w-0"> | |
| <div className="flex flex-wrap items-center justify-between gap-2"> | |
| <div> | |
| <h3 className="text-base font-semibold text-gray-900"> | |
| General Information | |
| </h3> | |
| <p className="text-sm text-gray-600"> | |
| Update the global inspection details once, then apply to all pages. | |
| </p> | |
| </div> | |
| <div className="flex flex-wrap gap-2"> | |
| {generalDirty ? ( | |
| <button | |
| type="button" | |
| onClick={applyGeneralToAll} | |
| 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" | |
| > | |
| Apply general info to all pages | |
| </button> | |
| ) : null} | |
| </div> | |
| </div> | |
| <div className="mt-4 rounded-lg border border-gray-200 bg-white overflow-x-auto w-full max-w-full"> | |
| <table className="min-w-[560px] w-full text-sm"> | |
| <thead className="bg-gray-50 border-b border-gray-200"> | |
| <tr> | |
| <th className="px-3 py-2 text-left text-xs font-semibold text-gray-600 min-w-[180px]"> | |
| Field | |
| </th> | |
| <th className="px-3 py-2 text-left text-xs font-semibold text-gray-600"> | |
| Value | |
| </th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| {GENERAL_FIELDS.map((field) => ( | |
| <tr key={`general-${field.key}`} className="border-b border-gray-100"> | |
| <td className="px-3 py-2 text-xs font-semibold text-gray-700"> | |
| {field.label} | |
| </td> | |
| <td className="px-3 py-2"> | |
| <input | |
| type="text" | |
| className="w-full rounded-md border border-gray-200 px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-blue-200" | |
| value={generalTemplate[field.key] ?? getFallbackValue(field.key)} | |
| onChange={(event) => | |
| updateGeneralField(field.key, event.target.value) | |
| } | |
| /> | |
| </td> | |
| </tr> | |
| ))} | |
| </tbody> | |
| </table> | |
| </div> | |
| </div> | |
| <div className="w-full lg:w-[320px] shrink-0"> | |
| <div className="flex flex-wrap items-center justify-between gap-2"> | |
| <div> | |
| <h3 className="text-base font-semibold text-gray-900">Headings</h3> | |
| <p className="text-sm text-gray-600"> | |
| Edit heading numbers or add new rows. | |
| </p> | |
| </div> | |
| <button | |
| type="button" | |
| onClick={addHeadingRow} | |
| className="inline-flex items-center gap-2 rounded-md border border-gray-200 bg-white px-3 py-1 text-xs font-semibold text-gray-700 hover:bg-gray-50 transition" | |
| > | |
| Add | |
| </button> | |
| </div> | |
| <div className="mt-3 rounded-lg border border-gray-200 bg-white overflow-x-auto"> | |
| <table className="min-w-[360px] w-full text-sm"> | |
| <thead className="bg-gray-50 border-b border-gray-200"> | |
| <tr> | |
| <th className="px-3 py-2 text-left text-xs font-semibold text-gray-600"> | |
| Number | |
| </th> | |
| <th className="px-3 py-2 text-left text-xs font-semibold text-gray-600"> | |
| Heading | |
| </th> | |
| <th className="px-3 py-2 text-left text-xs font-semibold text-gray-600 w-20"> | |
| Actions | |
| </th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| {headings.length ? ( | |
| headings.map((heading, idx) => ( | |
| <tr key={`heading-${idx}`} className="border-b border-gray-100"> | |
| <td className="px-3 py-2 text-xs font-semibold text-gray-700"> | |
| <input | |
| type="text" | |
| className="w-full rounded-md border border-gray-200 px-2 py-1 text-xs focus:outline-none focus:ring-2 focus:ring-blue-200" | |
| value={heading.number} | |
| onChange={(event) => | |
| updateHeadingField(idx, "number", event.target.value) | |
| } | |
| /> | |
| </td> | |
| <td className="px-3 py-2 text-sm text-gray-700"> | |
| <input | |
| type="text" | |
| className="w-full rounded-md border border-gray-200 px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-blue-200" | |
| value={heading.name} | |
| onChange={(event) => | |
| updateHeadingField(idx, "name", event.target.value) | |
| } | |
| /> | |
| </td> | |
| <td className="px-3 py-2 text-xs"> | |
| <button | |
| type="button" | |
| onClick={() => removeHeadingRow(idx)} | |
| className="inline-flex items-center gap-1 rounded-md border border-red-200 bg-red-50 px-2 py-1 text-[11px] font-semibold text-red-700 hover:bg-red-100" | |
| > | |
| Remove | |
| </button> | |
| </td> | |
| </tr> | |
| )) | |
| ) : ( | |
| <tr> | |
| <td | |
| colSpan={3} | |
| className="px-3 py-4 text-sm text-gray-500 text-center" | |
| > | |
| No headings found. | |
| </td> | |
| </tr> | |
| )} | |
| </tbody> | |
| </table> | |
| </div> | |
| </div> | |
| </div> | |
| </section> | |
| <section className="mb-4 rounded-lg border border-gray-200 bg-white p-3"> | |
| <div className="flex flex-wrap items-center justify-between gap-2"> | |
| <div> | |
| <h3 className="text-sm font-semibold text-gray-900">Job Sheet Sections</h3> | |
| <p className="text-xs text-gray-600"> | |
| Rename sections and add pages directly inside a section. | |
| </p> | |
| </div> | |
| <div className="flex items-center gap-2"> | |
| <button | |
| type="button" | |
| onClick={() => setSectionsCollapsed((prev) => !prev)} | |
| className="inline-flex items-center gap-2 rounded-md border border-gray-200 bg-white px-3 py-1 text-xs font-semibold text-gray-700 hover:bg-gray-50 transition" | |
| > | |
| {sectionsCollapsed ? "Expand" : "Collapse"} | |
| </button> | |
| <button | |
| type="button" | |
| onClick={addSection} | |
| className="inline-flex items-center gap-2 rounded-md border border-gray-200 bg-white px-3 py-1 text-xs font-semibold text-gray-700 hover:bg-gray-50 transition" | |
| > | |
| Add section | |
| </button> | |
| </div> | |
| </div> | |
| {sectionsCollapsed ? ( | |
| <div className="mt-2 text-[11px] text-gray-500"> | |
| {sections.length} section{sections.length === 1 ? "" : "s"} ·{" "} | |
| {sections.reduce((sum, section) => sum + (section.pages?.length ?? 0), 0)}{" "} | |
| page{sections.length === 1 ? "" : "s"} total | |
| </div> | |
| ) : ( | |
| <div className="mt-2 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2"> | |
| {sections.map((section, idx) => ( | |
| <div | |
| key={section.id} | |
| className="rounded-md border border-gray-200 bg-gray-50 p-2" | |
| > | |
| <div className="flex items-center justify-between gap-2"> | |
| <div className="text-[11px] font-semibold text-gray-700"> | |
| Section {idx + 1} | |
| </div> | |
| <button | |
| type="button" | |
| onClick={() => insertPageIntoSection(section.id)} | |
| className="inline-flex items-center gap-1 rounded-md border border-gray-200 bg-white px-2 py-0.5 text-[11px] font-semibold text-gray-700 hover:bg-gray-50 transition" | |
| > | |
| Add page | |
| </button> | |
| </div> | |
| <div className="mt-1 text-[11px] text-gray-500"> | |
| Pages: {section.pages?.length ?? 0} | |
| </div> | |
| <div className="text-[11px] text-gray-500"> | |
| Page numbers:{" "} | |
| {(() => { | |
| const start = | |
| sections | |
| .slice(0, idx) | |
| .reduce((sum, item) => sum + (item.pages?.length ?? 0), 0) + | |
| 1; | |
| const count = section.pages?.length ?? 0; | |
| if (count <= 0) return "-"; | |
| const end = start + count - 1; | |
| return start === end ? `${start}` : `${start}-${end}`; | |
| })()} | |
| </div> | |
| <label className="mt-1 block text-[11px] text-gray-600"> | |
| Title | |
| <input | |
| type="text" | |
| className="mt-1 w-full rounded-md border border-gray-200 px-2 py-0.5 text-xs focus:outline-none focus:ring-2 focus:ring-blue-200" | |
| value={section.title ?? ""} | |
| onChange={(event) => | |
| updateSectionTitle(section.id, event.target.value) | |
| } | |
| placeholder={`Section ${idx + 1}`} | |
| /> | |
| </label> | |
| </div> | |
| ))} | |
| </div> | |
| )} | |
| </section> | |
| <div className="rounded-lg border border-gray-200 bg-white overflow-x-auto"> | |
| <div className="px-3 py-2 text-xs text-gray-500 bg-gray-50 border-b border-gray-200"> | |
| General Info (double-click the header below to expand/collapse columns) | |
| </div> | |
| <table className="min-w-[2400px] w-full text-sm"> | |
| <thead className="bg-gray-50 border-b border-gray-200"> | |
| <tr> | |
| <th | |
| rowSpan={2} | |
| className="px-3 py-2 text-left text-xs font-semibold text-gray-600" | |
| > | |
| Page | |
| </th> | |
| <th | |
| colSpan={showGeneralColumns ? GENERAL_FIELDS.length : 1} | |
| onDoubleClick={() => setShowGeneralColumns((prev) => !prev)} | |
| className="px-3 py-2 text-left text-xs font-semibold text-gray-600 cursor-pointer select-none" | |
| title="Double-click to toggle general columns" | |
| > | |
| General Info | |
| </th> | |
| <th | |
| colSpan={ITEM_FIELDS.length + 1} | |
| className="px-3 py-2 text-left text-xs font-semibold text-gray-600" | |
| > | |
| Item Details | |
| </th> | |
| <th | |
| rowSpan={2} | |
| className="px-3 py-2 text-left text-xs font-semibold text-gray-600 min-w-[520px]" | |
| > | |
| Actions | |
| </th> | |
| </tr> | |
| <tr> | |
| {showGeneralColumns ? ( | |
| GENERAL_FIELDS.map((field) => ( | |
| <th | |
| key={`general-col-${field.key}`} | |
| className="px-3 py-2 text-left text-xs font-semibold text-gray-600 min-w-[180px]" | |
| > | |
| {field.label} | |
| </th> | |
| )) | |
| ) : ( | |
| <th className="px-3 py-2 text-left text-xs font-semibold text-gray-500 min-w-[180px]"> | |
| General info hidden | |
| </th> | |
| )} | |
| {ITEM_FIELDS.map((field) => ( | |
| <th | |
| key={`item-col-${field.key}`} | |
| className="px-3 py-2 text-left text-xs font-semibold text-gray-600 min-w-[180px]" | |
| > | |
| {field.label} | |
| </th> | |
| ))} | |
| <th className="px-3 py-2 text-left text-xs font-semibold text-gray-600 min-w-[320px]"> | |
| Images | |
| </th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| {flatPages.map((entry) => { | |
| const pageIndex = entry.flatIndex; | |
| const template = entry.page.template ?? {}; | |
| const photoIds = entry.page.photo_ids ?? []; | |
| const orderLocked = entry.page.photo_order_locked ?? false; | |
| const photoLookup = new Map( | |
| (session?.uploads?.photos ?? []).map((photo) => [photo.id, photo]), | |
| ); | |
| const availablePhotos = (session?.uploads?.photos ?? []).filter( | |
| (photo) => !photoIds.includes(photo.id), | |
| ); | |
| return ( | |
| <tr key={`row-${entry.sectionId}-${entry.pageIndex}`} className="border-b border-gray-100"> | |
| <td className="px-3 py-2 text-xs font-semibold text-gray-700"> | |
| Page {entry.flatIndex + 1} | |
| </td> | |
| {showGeneralColumns ? ( | |
| GENERAL_FIELDS.map((field) => ( | |
| <td | |
| key={`${pageIndex}-general-${field.key}`} | |
| className="px-3 py-2 min-w-[180px]" | |
| > | |
| <input | |
| type="text" | |
| className="w-full rounded-md border border-gray-200 px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-blue-200" | |
| value={template[field.key] ?? getFallbackValue(field.key)} | |
| onChange={(event) => | |
| updateField(pageIndex, field.key, event.target.value) | |
| } | |
| /> | |
| </td> | |
| )) | |
| ) : ( | |
| <td className="px-3 py-2 min-w-[180px] text-xs text-gray-500"> | |
| (hidden) | |
| </td> | |
| )} | |
| {ITEM_FIELDS.map((field) => ( | |
| <td key={`${pageIndex}-${field.key}`} className="px-3 py-2 min-w-[180px]"> | |
| {field.multiline ? ( | |
| <textarea | |
| rows={2} | |
| className="w-full rounded-md border border-gray-200 px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-blue-200" | |
| value={template[field.key] ?? getFallbackValue(field.key)} | |
| onChange={(event) => | |
| updateField(pageIndex, field.key, event.target.value) | |
| } | |
| /> | |
| ) : ( | |
| <input | |
| type="text" | |
| className="w-full rounded-md border border-gray-200 px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-blue-200" | |
| value={template[field.key] ?? getFallbackValue(field.key)} | |
| onChange={(event) => | |
| updateField(pageIndex, field.key, event.target.value) | |
| } | |
| /> | |
| )} | |
| </td> | |
| ))} | |
| <td className="px-3 py-2 min-w-[320px]"> | |
| <div className="space-y-2"> | |
| {photoIds.length ? ( | |
| photoIds.map((photoId, idx) => { | |
| const photo = photoLookup.get(photoId); | |
| return ( | |
| <div key={`${pageIndex}-photo-${photoId}`} className="flex flex-wrap items-center gap-2 text-xs"> | |
| <span className="font-semibold text-gray-700"> | |
| {photo?.name || photoId} | |
| </span> | |
| <button | |
| type="button" | |
| onClick={() => movePhoto(pageIndex, idx, idx - 1)} | |
| disabled={idx === 0} | |
| className="rounded border border-gray-200 px-2 py-0.5 text-[11px] text-gray-600 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed" | |
| > | |
| Up | |
| </button> | |
| <button | |
| type="button" | |
| onClick={() => movePhoto(pageIndex, idx, idx + 1)} | |
| disabled={idx === photoIds.length - 1} | |
| className="rounded border border-gray-200 px-2 py-0.5 text-[11px] text-gray-600 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed" | |
| > | |
| Down | |
| </button> | |
| <button | |
| type="button" | |
| onClick={() => removePhoto(pageIndex, idx)} | |
| className="rounded border border-red-200 bg-red-50 px-2 py-0.5 text-[11px] font-semibold text-red-700 hover:bg-red-100" | |
| > | |
| Remove | |
| </button> | |
| </div> | |
| ); | |
| }) | |
| ) : ( | |
| <div className="text-xs text-gray-500">No images linked.</div> | |
| )} | |
| <div className="flex flex-wrap items-center gap-2"> | |
| <select | |
| className="rounded-md border border-gray-200 px-2 py-1 text-xs focus:outline-none focus:ring-2 focus:ring-blue-200" | |
| value={photoSelections[pageIndex] ?? ""} | |
| onChange={(event) => | |
| updatePhotoSelection(pageIndex, event.target.value) | |
| } | |
| > | |
| <option value="">Select image</option> | |
| {availablePhotos.map((photo) => ( | |
| <option key={`photo-option-${photo.id}`} value={photo.id}> | |
| {photo.name} | |
| </option> | |
| ))} | |
| </select> | |
| <button | |
| type="button" | |
| onClick={() => | |
| addPhotoToPage(pageIndex, photoSelections[pageIndex] ?? "") | |
| } | |
| disabled={!photoSelections[pageIndex]} | |
| className="rounded border border-gray-200 px-2 py-1 text-xs font-semibold text-gray-700 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed" | |
| > | |
| Add | |
| </button> | |
| <button | |
| type="button" | |
| onClick={() => uploadInputRefs.current[pageIndex]?.click()} | |
| disabled={isUploading} | |
| className="rounded border border-gray-200 px-2 py-1 text-xs font-semibold text-gray-700 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed" | |
| > | |
| Upload | |
| </button> | |
| <button | |
| type="button" | |
| onClick={() => setPhotoOrderLocked(pageIndex, !orderLocked)} | |
| disabled={photoIds.length === 0} | |
| className="rounded border border-gray-200 px-2 py-1 text-xs font-semibold text-gray-700 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed" | |
| > | |
| {orderLocked ? "Auto order" : "Apply order"} | |
| </button> | |
| <span className="text-[11px] text-gray-500"> | |
| {orderLocked ? "Manual order" : "Auto order"} | |
| </span> | |
| <input | |
| ref={(node) => { | |
| uploadInputRefs.current[pageIndex] = node; | |
| }} | |
| type="file" | |
| accept="image/*" | |
| className="hidden" | |
| onChange={(event) => { | |
| const file = event.target.files?.[0]; | |
| if (file) uploadPhotoForPage(pageIndex, file); | |
| event.target.value = ""; | |
| }} | |
| /> | |
| </div> | |
| </div> | |
| </td> | |
| <td className="px-3 py-2 min-w-[520px]"> | |
| <div className="flex flex-wrap items-center gap-2"> | |
| <button | |
| type="button" | |
| onClick={() => applyRowToAll(pageIndex)} | |
| className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-xs font-semibold text-gray-700 hover:bg-gray-50 transition" | |
| > | |
| Apply row to all | |
| </button> | |
| <button | |
| type="button" | |
| onClick={() => insertPageAt(pageIndex, template)} | |
| className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-xs font-semibold text-gray-700 hover:bg-gray-50 transition" | |
| > | |
| Insert above | |
| </button> | |
| <button | |
| type="button" | |
| onClick={() => insertPageAt(pageIndex + 1, template)} | |
| className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-xs font-semibold text-gray-700 hover:bg-gray-50 transition" | |
| > | |
| Insert below | |
| </button> | |
| <button | |
| type="button" | |
| onClick={() => removePageAt(pageIndex)} | |
| disabled={flatPages.length <= 1} | |
| className="inline-flex items-center gap-2 rounded-lg border border-red-200 bg-red-50 px-3 py-1.5 text-xs font-semibold text-red-700 hover:bg-red-100 transition disabled:opacity-50 disabled:cursor-not-allowed" | |
| > | |
| Delete page | |
| </button> | |
| </div> | |
| </td> | |
| </tr> | |
| ); | |
| })} | |
| </tbody> | |
| </table> | |
| </div> | |
| <div className="mt-3 flex justify-end"> | |
| <button | |
| type="button" | |
| onClick={() => insertPageAt(flatPages.length, flatPages[flatPages.length - 1]?.page?.template)} | |
| disabled={!sessionId} | |
| 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" | |
| > | |
| Add page at end | |
| </button> | |
| </div> | |
| <PageFooter note="Tip: changes save automatically. Use apply row to keep pages consistent." /> | |
| <SaveBeforeLeaveDialog | |
| open={Boolean(pendingNavigationTarget)} | |
| onCancel={() => setPendingNavigationTarget(null)} | |
| onProceed={leaveWithoutWaiting} | |
| onWait={waitForSaveAndContinue} | |
| /> | |
| </PageShell> | |
| ); | |
| } | |