Spaces:
Sleeping
Sleeping
| 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<string, RatingTone> = { | |
| "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<string, RatingTone> = { | |
| "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<string, RatingTone>) { | |
| 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<string, number>): LayoutEntry[] { | |
| const entries = photos.map((photo) => { | |
| const key = photoKey(photo); | |
| return { | |
| photo, | |
| ratio: key ? ratios[key] ?? 1 : 1, | |
| }; | |
| }); | |
| const memo = new Map<string, { cost: number; rows: number[][] }>(); | |
| 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 ( | |
| <div | |
| className={[ | |
| "min-h-[120px] w-full rounded-lg border border-dashed border-gray-300 bg-gray-50 flex items-center justify-center text-xs text-gray-500 break-inside-avoid mb-3", | |
| className, | |
| ].join(" ")} | |
| style={{ breakInside: "avoid", pageBreakInside: "avoid" }} | |
| > | |
| No photo selected | |
| </div> | |
| ); | |
| } | |
| return ( | |
| <figure | |
| className={[ | |
| "rounded-lg border border-gray-200 bg-gray-50 p-2 pb-3 break-inside-avoid flex flex-col gap-1 overflow-hidden", | |
| className, | |
| ].join(" ")} | |
| style={{ breakInside: "avoid", pageBreakInside: "avoid" }} | |
| > | |
| <div className="w-full flex-1 flex items-center justify-center"> | |
| <img | |
| src={url} | |
| alt={label} | |
| className={["w-full object-contain max-h-[240px]", imageClassName].join( | |
| " ", | |
| )} | |
| loading="eager" | |
| /> | |
| </div> | |
| <figcaption className="text-[10px] text-gray-600 text-center break-all leading-tight"> | |
| {label} | |
| </figcaption> | |
| </figure> | |
| ); | |
| } | |
| 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<Record<string, number>>({}); | |
| 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 ( | |
| <div className="w-full h-full p-5 text-[11px] text-gray-700 flex flex-col"> | |
| <header className="mb-3 border-b border-gray-200 pb-2"> | |
| <div className="grid grid-cols-[auto,1fr,auto] items-center gap-3"> | |
| <img | |
| src="/assets/prosento-logo.png" | |
| alt="Prosento logo" | |
| className="h-14 w-auto object-contain" | |
| /> | |
| <div className="text-center leading-tight"> | |
| <div className="text-base font-semibold text-gray-900"> | |
| {docNumber || "-"} | |
| </div> | |
| </div> | |
| {logoUrl ? ( | |
| <img | |
| src={logoUrl} | |
| alt="Company logo" | |
| className="h-14 w-auto object-contain" | |
| /> | |
| ) : ( | |
| <div className="h-14 min-w-[140px] rounded-md border border-dashed border-gray-300 px-2 text-[9px] font-semibold text-gray-400 flex items-center justify-center text-center"> | |
| Company Logo not found | |
| </div> | |
| )} | |
| </div> | |
| </header> | |
| {variant === "full" ? ( | |
| <section className="mb-2" aria-labelledby="observations-title"> | |
| <h2 | |
| id="observations-title" | |
| className="text-[10px] font-semibold text-gray-800 border-b border-gray-200 pb-1 mb-1" | |
| > | |
| Observations and Findings | |
| </h2> | |
| <div className="grid grid-cols-1 md:grid-cols-2 gap-2"> | |
| <div className="md:col-span-2"> | |
| <div className="grid grid-cols-3 gap-2"> | |
| <div className="space-y-0.5"> | |
| <div className="text-[9px] font-medium text-gray-500">Ref</div> | |
| <div className="template-field text-[10px] font-semibold text-gray-900"> | |
| {reference} | |
| </div> | |
| </div> | |
| <div className="space-y-0.5"> | |
| <div className="text-[9px] font-medium text-gray-500">Area</div> | |
| <div className="template-field text-[10px] font-semibold text-gray-900"> | |
| {area} | |
| </div> | |
| </div> | |
| <div className="space-y-0.5"> | |
| <div className="text-[9px] font-medium text-gray-500">Location</div> | |
| <div className="template-field text-[10px] font-semibold text-gray-900"> | |
| {functionalLocation} | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div className="md:col-span-2 flex justify-center"> | |
| <div className="inline-flex items-center gap-4"> | |
| <div className="text-center space-y-1"> | |
| <div className="text-[9px] font-medium text-gray-500"> | |
| Category | |
| </div> | |
| <span | |
| className={[ | |
| "template-field inline-flex items-center justify-center rounded-md border px-4 py-1 text-[10px] font-semibold min-w-[120px]", | |
| categoryBadge.className, | |
| ].join(" ")} | |
| > | |
| {categoryBadge.text} | |
| </span> | |
| </div> | |
| <div className="text-center space-y-1"> | |
| <div className="text-[9px] font-medium text-gray-500"> | |
| Priority | |
| </div> | |
| <span | |
| className={[ | |
| "template-field inline-flex items-center justify-center rounded-md border px-4 py-1 text-[10px] font-semibold min-w-[120px]", | |
| priorityBadge.className, | |
| ].join(" ")} | |
| > | |
| {priorityBadge.text} | |
| </span> | |
| </div> | |
| </div> | |
| </div> | |
| <div className="md:col-span-2 space-y-1"> | |
| <div className="text-[9px] font-medium text-gray-500"> | |
| Condition Description | |
| </div> | |
| <div className="bg-gray-50 border-l-4 border-gray-300 p-1.5 rounded-sm"> | |
| <p className="template-field template-field-multiline text-gray-700 text-[9px] font-medium leading-snug"> | |
| {conditionText} | |
| </p> | |
| </div> | |
| </div> | |
| <div className="md:col-span-2 space-y-1"> | |
| <div className="text-[9px] font-medium text-gray-500"> | |
| Action Required | |
| </div> | |
| <div className="bg-gray-50 border-l-4 border-gray-300 p-1.5 rounded-sm"> | |
| <p className="template-field template-field-multiline text-gray-700 text-[9px] font-medium leading-snug"> | |
| {actionText} | |
| </p> | |
| </div> | |
| </div> | |
| </div> | |
| </section> | |
| ) : null} | |
| <section className="mb-3 avoid-break flex-1 min-h-0 flex flex-col"> | |
| <div className="text-[10px] font-semibold text-gray-800 border-b border-gray-200 pb-1 mb-2"> | |
| {variant === "photos" ? "Photo Documentation (continued)" : "Photo Documentation"} | |
| </div> | |
| <div className={`${photoGridClass} flex-1 items-stretch`}> | |
| {displayedPhotos.length === 0 ? ( | |
| <PhotoSlot url={undefined} label="No photo selected" className="h-full" /> | |
| ) : ( | |
| displayedPhotos.map((photo, index) => ( | |
| <PhotoSlot | |
| key={photo?.id || `${index}`} | |
| url={photo?.url} | |
| label={figureCaption || photo?.name || `Figure ${index + 1}`} | |
| className="h-full" | |
| /> | |
| )) | |
| )} | |
| </div> | |
| </section> | |
| <footer className="mt-2 text-[10px] text-gray-500 flex flex-col items-center gap-1"> | |
| <div className="flex flex-wrap items-center justify-center gap-3"> | |
| <span>Date: {inspectionDate || "-"}</span> | |
| <span>Inspector: {inspector || "-"}</span> | |
| <span>Doc: {docNumber || "-"}</span> | |
| </div> | |
| <div className="text-[10px] font-semibold text-gray-600"> | |
| RepEx Inspection Job Sheet | |
| </div> | |
| <div className="text-[10px] text-gray-500"> | |
| {sectionLabel ? `${sectionLabel} - ` : ""} | |
| Page {pageIndex + 1} of {pageCount} | |
| </div> | |
| </footer> | |
| </div> | |
| ); | |
| } | |