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): 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(null); const [sections, setSections] = useState([]); 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(""); const [sectionsCollapsed, setSectionsCollapsed] = useState(true); const [headings, setHeadings] = useState([]); const [showGeneralColumns, setShowGeneralColumns] = useState(false); const [generalDirty, setGeneralDirty] = useState(false); const [generalTemplate, setGeneralTemplate] = useState({}); const [uploadProgress, setUploadProgress] = useState(null); const [pendingNavigationTarget, setPendingNavigationTarget] = useState< string | null >(null); const [photoSelections, setPhotoSelections] = useState>( {}, ); const saveTimerRef = useRef(null); const generalApplyTimerRef = useRef(null); const headingsSaveTimerRef = useRef(null); const lastSavedHeadingsRef = useRef(JSON.stringify([])); const headingsLoadedRef = useRef(false); const lastSavedRef = useRef(""); const pendingSaveRef = useRef(""); const savePromiseRef = useRef | null>(null); const excelInputRef = useRef(null); const jsonInputRef = useRef(null); const uploadInputRefs = useRef>({}); const uploadTimerRef = useRef(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(`/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(`/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(); 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(`/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(`/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( `/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 (
Saving...
); } if (saveState === "pending") { return (
Unsaved changes
); } if (saveState === "error") { return (
Save failed
); } return (
All changes saved
); }, [saveState]); return ( {saveIndicator} { 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" > Back } />