import { useEffect, useMemo, useState } from "react"; import type { FileMeta, Session, TemplateFields } from "../types/session"; import { formatDocNumber } from "../lib/report"; type JobSheetTemplateProps = { session: Session | null; pageIndex: number; pageCount: number; template?: TemplateFields; photos?: FileMeta[]; orderLocked?: boolean; variant?: "full" | "photos"; sectionLabel?: string; photoLayout?: "auto" | "two-column" | "stacked"; }; type PhotoSlotProps = { url?: string; label: string; className?: string; imageClassName?: string; }; type LayoutEntry = { photo: FileMeta; span: boolean; }; type RatingTone = { label: string; bg: string; text: string; border: string; }; const CATEGORY_SCALE: Record = { "0": { label: "(100% Original Strength)", bg: "bg-green-100", text: "text-green-800", border: "border-green-200", }, "1": { label: "(100% Original Strength)", bg: "bg-green-200", text: "text-green-800", border: "border-green-200", }, "2": { label: "(95-100% Original Strength)", bg: "bg-yellow-100", text: "text-yellow-800", border: "border-yellow-200", }, "3": { label: "(75-95% Original Strength)", bg: "bg-yellow-200", text: "text-yellow-800", border: "border-yellow-200", }, "4": { label: "(50-75% Original Strength)", bg: "bg-orange-200", text: "text-orange-800", border: "border-orange-200", }, "5": { label: "(<50% Original Strength)", bg: "bg-red-200", text: "text-red-800", border: "border-red-200", }, }; const PRIORITY_SCALE: Record = { "1": { label: "(Immediate)", bg: "bg-red-200", text: "text-red-800", border: "border-red-200", }, "2": { label: "(1 Year)", bg: "bg-orange-200", text: "text-orange-800", border: "border-orange-200", }, "3": { label: "(3 Years)", bg: "bg-green-200", text: "text-green-800", border: "border-green-200", }, X: { label: "(At Use)", bg: "bg-purple-200", text: "text-purple-800", border: "border-purple-200", }, M: { label: "(Monitor)", bg: "bg-blue-200", text: "text-blue-800", border: "border-blue-200", }, }; function ratingKey(value: string) { const raw = (value || "").trim(); if (!raw) return ""; const match = raw.match(/^([0-9]|[xXmM])/); if (match) return match[1].toUpperCase(); return raw.split("")[0].trim().toUpperCase(); } function formatRating(value: string, scale: Record) { const raw = (value || "").trim(); const key = ratingKey(raw); const tone = key ? scale[key] : undefined; if (!tone) { return { text: raw || "", className: "bg-gray-50 text-gray-700 border-gray-200", }; } return { text: `${key} ${tone.label}`, className: `${tone.bg} ${tone.text} ${tone.border}`, }; } function normalizeKey(value: string) { return value.toLowerCase().replace(/[^a-z0-9]/g, ""); } function photoKey(photo: FileMeta) { return photo.id || photo.url || photo.name || ""; } function photoUrl(photo: FileMeta) { return photo.url || ""; } function resolveLogoUrl(session: Session | null, rawValue?: string) { const value = (rawValue || "").trim(); if (!value) return ""; if (/^(https?:|data:|\/)/i.test(value)) return value; const uploads = session?.uploads?.photos ?? []; const key = normalizeKey(value); for (const photo of uploads) { if (photo.id && value === photo.id) { return photo.url || ""; } const name = photo.name || ""; if (!name) continue; const nameKey = normalizeKey(name); const stemKey = normalizeKey(name.replace(/\.[^/.]+$/, "")); if (key == nameKey || key == stemKey) { return photo.url || ""; } } return ""; } function computeLayout(photos: FileMeta[], ratios: Record): LayoutEntry[] { const entries = photos.map((photo) => { const key = photoKey(photo); return { photo, ratio: key ? ratios[key] ?? 1 : 1, }; }); const memo = new Map(); function solve(remaining: number[]): { cost: number; rows: number[][] } { if (remaining.length == 0) { return { cost: 0, rows: [] }; } const cacheKey = remaining.join(","); const cached = memo.get(cacheKey); if (cached) return cached; const [first, ...rest] = remaining; let bestCost = Number.POSITIVE_INFINITY; let bestRows: number[][] = []; const single = solve(rest); const singleCost = 2 * entries[first].ratio + single.cost; if (singleCost < bestCost) { bestCost = singleCost; bestRows = [[first], ...single.rows]; } for (let i = 0; i < rest.length; i += 1) { const pair = rest[i]; const next = rest.filter((_, idx) => idx != i); const result = solve(next); const pairCost = Math.max(entries[first].ratio, entries[pair].ratio) + result.cost; if (pairCost < bestCost) { bestCost = pairCost; bestRows = [[first, pair], ...result.rows]; } } const value = { cost: bestCost, rows: bestRows }; memo.set(cacheKey, value); return value; } const indices = entries.map((_, index) => index); const solution = solve(indices); const layout: LayoutEntry[] = []; solution.rows.forEach((row) => { if (row.length == 1) { layout.push({ photo: entries[row[0]].photo, span: true }); } else { layout.push({ photo: entries[row[0]].photo, span: false }); layout.push({ photo: entries[row[1]].photo, span: false }); } }); return layout; } function PhotoSlot({ url, label, className = "", imageClassName = "" }: PhotoSlotProps) { if (!url) { return (
No photo selected
); } return (
{label}
{label}
); } export function JobSheetTemplate({ session, pageIndex, pageCount, template, photos, orderLocked = false, variant = "full", sectionLabel, photoLayout = "auto", }: JobSheetTemplateProps) { const inspectionDate = template?.inspection_date ?? session?.inspection_date ?? ""; const inspector = template?.inspector ?? ""; const docNumber = template?.document_no ?? session?.document_no ?? (session?.id ? formatDocNumber(session) : ""); const companyLogo = template?.company_logo ?? ""; const figureCaption = template?.figure_caption ?? ""; const reference = template?.reference ?? ""; const area = template?.area ?? ""; const itemDescription = template?.item_description ?? ""; const functionalLocation = template?.functional_location ?? ""; const category = template?.category ?? ""; const priority = template?.priority ?? ""; const requiredAction = template?.required_action ?? ""; const conditionText = itemDescription; const actionText = requiredAction; const categoryBadge = formatRating(category, CATEGORY_SCALE); const priorityBadge = formatRating(priority, PRIORITY_SCALE); const resolvedPhotos = photos && photos.length ? photos : []; const limitedPhotos = resolvedPhotos.slice(0, 6); const logoUrl = resolveLogoUrl(session, companyLogo); const [ratios, setRatios] = useState>({}); useEffect(() => { let active = true; const pending = limitedPhotos.filter((photo) => { const key = photoKey(photo); return key && !ratios[key] && photoUrl(photo); }); if (!pending.length) return undefined; pending.forEach((photo) => { const key = photoKey(photo); const url = photoUrl(photo); if (!key || !url) return; const img = new Image(); img.onload = () => { if (!active) return; const ratio = img.naturalWidth ? img.naturalHeight / img.naturalWidth : 1; setRatios((prev) => ({ ...prev, [key]: ratio || 1 })); }; img.src = url; }); return () => { active = false; }; }, [limitedPhotos, ratios]); const orderedPhotos = useMemo(() => { if (!limitedPhotos.length) return []; if (orderLocked) return limitedPhotos; const layout = computeLayout(limitedPhotos, ratios); return layout.map((entry) => entry.photo); }, [limitedPhotos, ratios, orderLocked]); const displayedPhotos = variant === "full" ? orderedPhotos.slice(0, 2) : orderedPhotos; const normalizedLayout = (photoLayout || "auto").toLowerCase(); const layoutMode = normalizedLayout === "stacked" || normalizedLayout === "two-column" ? normalizedLayout : "auto"; const photoGridClass = layoutMode === "stacked" ? "grid grid-cols-1 gap-3" : layoutMode === "two-column" ? "grid grid-cols-2 gap-3" : displayedPhotos.length <= 1 ? "grid grid-cols-1 gap-3" : "grid grid-cols-2 gap-3"; return (
Prosento logo
{docNumber || "-"}
{logoUrl ? ( Company logo ) : (
Company Logo not found
)}
{variant === "full" ? (

Observations and Findings

Ref
{reference}
Area
{area}
Location
{functionalLocation}
Category
{categoryBadge.text}
Priority
{priorityBadge.text}
Condition Description

{conditionText}

Action Required

{actionText}

) : null}
{variant === "photos" ? "Photo Documentation (continued)" : "Photo Documentation"}
{displayedPhotos.length === 0 ? ( ) : ( displayedPhotos.map((photo, index) => ( )) )}
); }