Prosento_RepEx / frontend /src /components /JobSheetTemplate.tsx
ChristopherJKoen's picture
Standardize rating labels and sync PDF export
1ebea81
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>
);
}