mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-08 11:37:25 +00:00
updated the UI to manage document uploads down to measure level
This commit is contained in:
parent
785c40f2d1
commit
2536f3eb8e
5 changed files with 346 additions and 116 deletions
|
|
@ -52,6 +52,7 @@ type Props = {
|
|||
portfolioId: string;
|
||||
onClose: () => void;
|
||||
docStatusMap?: DocStatusMap;
|
||||
approvedMeasures?: string[]; // if non-empty, used instead of proposedMeasures
|
||||
};
|
||||
|
||||
// ── Constants ─────────────────────────────────────────────────────────────
|
||||
|
|
@ -261,29 +262,122 @@ function PasGuidancePanel() {
|
|||
|
||||
// ── DocType select ────────────────────────────────────────────────────────
|
||||
|
||||
function DocTypeSelect({
|
||||
// ── DocType button grid — shown when a measure is selected ───────────────
|
||||
|
||||
function DocTypeButtonGrid({
|
||||
value,
|
||||
onChange,
|
||||
showHint = false,
|
||||
requiredDocs,
|
||||
uploadedDocs,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
showHint?: boolean;
|
||||
requiredDocs?: string[]; // file types required for the selected measure
|
||||
uploadedDocs?: string[]; // file types already uploaded for the selected measure
|
||||
requiredDocs: string[];
|
||||
uploadedDocs: string[];
|
||||
}) {
|
||||
const [showOther, setShowOther] = useState(false);
|
||||
const uploadedSet = new Set(uploadedDocs);
|
||||
const requiredSet = new Set(requiredDocs);
|
||||
const isOtherSelected = value !== "" && !requiredSet.has(value);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{/* Required doc type buttons */}
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{requiredDocs.map((docType) => {
|
||||
const option = FILE_TYPE_OPTIONS.find((o) => o.value === docType);
|
||||
const label = option?.label ?? docType;
|
||||
const alreadyUploaded = uploadedSet.has(docType);
|
||||
const isSelected = value === docType;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={docType}
|
||||
type="button"
|
||||
onClick={() => { onChange(docType); setShowOther(false); }}
|
||||
title={alreadyUploaded ? `${label} — already uploaded` : label}
|
||||
className={`inline-flex items-center gap-1 px-2.5 py-1.5 rounded-lg border text-xs font-medium transition-all duration-100 ${
|
||||
isSelected
|
||||
? "bg-brandblue text-white border-brandblue shadow-sm"
|
||||
: alreadyUploaded
|
||||
? "bg-emerald-50 text-emerald-700 border-emerald-200 hover:border-emerald-400"
|
||||
: "bg-white text-gray-700 border-gray-200 hover:border-brandblue/50 hover:bg-brandlightblue/10"
|
||||
}`}
|
||||
>
|
||||
{alreadyUploaded && !isSelected && (
|
||||
<svg className="h-3 w-3 text-emerald-500 shrink-0" viewBox="0 0 12 12" fill="none">
|
||||
<path d="M2 6l3 3 5-5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
)}
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Other button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowOther((v) => !v)}
|
||||
className={`inline-flex items-center gap-1 px-2.5 py-1.5 rounded-lg border text-xs font-medium transition-all duration-100 ${
|
||||
isOtherSelected || showOther
|
||||
? "bg-gray-100 text-gray-700 border-gray-300"
|
||||
: "bg-white text-gray-400 border-gray-200 hover:border-gray-400 hover:text-gray-600"
|
||||
}`}
|
||||
>
|
||||
Other {showOther ? "▲" : "▼"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Other: dropdown for non-required types */}
|
||||
{(showOther || isOtherSelected) && (
|
||||
<Select
|
||||
value={isOtherSelected ? value : "__unset__"}
|
||||
onValueChange={(v) => { onChange(v === "__unset__" ? "" : v); }}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs w-full">
|
||||
<SelectValue placeholder="Select other type…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__unset__" className="text-xs text-gray-400">Select other type…</SelectItem>
|
||||
{FILE_TYPE_GROUPS.map((group) => {
|
||||
const items = FILE_TYPE_OPTIONS.filter(
|
||||
(o) => o.group === group && !requiredSet.has(o.value),
|
||||
);
|
||||
if (!items.length) return null;
|
||||
return (
|
||||
<SelectGroup key={group}>
|
||||
<SelectLabel className="text-[10px] font-semibold uppercase tracking-wide text-gray-400 px-2 py-1">
|
||||
{group}
|
||||
</SelectLabel>
|
||||
{items.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value} className="text-xs">{o.label}</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
{/* Show hint for selected type */}
|
||||
{value && (() => {
|
||||
const hint = FILE_TYPE_OPTIONS.find((o) => o.value === value)?.hint;
|
||||
return hint ? <p className="text-[10px] text-blue-600 leading-snug">{hint}</p> : null;
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── DocType select — fallback when no measure selected ────────────────────
|
||||
|
||||
function DocTypeSelect({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
}) {
|
||||
const selected = FILE_TYPE_OPTIONS.find((o) => o.value === value);
|
||||
const requiredSet = new Set(requiredDocs ?? []);
|
||||
const uploadedSet = new Set(uploadedDocs ?? []);
|
||||
|
||||
// If we have required docs, show them as a priority group first
|
||||
const priorityItems = requiredDocs && requiredDocs.length > 0
|
||||
? requiredDocs
|
||||
.map((t) => FILE_TYPE_OPTIONS.find((o) => o.value === t))
|
||||
.filter((o): o is typeof FILE_TYPE_OPTIONS[number] => !!o)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
|
|
@ -293,32 +387,8 @@ function DocTypeSelect({
|
|||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__unset__" className="text-xs text-gray-400">Select type…</SelectItem>
|
||||
|
||||
{/* Priority group: required docs for the selected measure */}
|
||||
{priorityItems.length > 0 && (
|
||||
<SelectGroup>
|
||||
<SelectLabel className="text-[10px] font-semibold uppercase tracking-wide text-blue-500 px-2 py-1">
|
||||
Required for this measure
|
||||
</SelectLabel>
|
||||
{priorityItems.map((o) => {
|
||||
const alreadyUploaded = uploadedSet.has(o.value);
|
||||
return (
|
||||
<SelectItem key={o.value} value={o.value} className="text-xs">
|
||||
{o.label}
|
||||
{alreadyUploaded && (
|
||||
<span className="ml-1.5 text-[10px] text-emerald-500">✓ uploaded</span>
|
||||
)}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectGroup>
|
||||
)}
|
||||
|
||||
{/* Remaining groups */}
|
||||
{FILE_TYPE_GROUPS.map((group) => {
|
||||
const items = FILE_TYPE_OPTIONS.filter(
|
||||
(o) => o.group === group && !requiredSet.has(o.value),
|
||||
);
|
||||
const items = FILE_TYPE_OPTIONS.filter((o) => o.group === group);
|
||||
if (!items.length) return null;
|
||||
return (
|
||||
<SelectGroup key={group}>
|
||||
|
|
@ -326,16 +396,14 @@ function DocTypeSelect({
|
|||
{group}
|
||||
</SelectLabel>
|
||||
{items.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value} className="text-xs">
|
||||
{o.label}
|
||||
</SelectItem>
|
||||
<SelectItem key={o.value} value={o.value} className="text-xs">{o.label}</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{showHint && selected?.hint && (
|
||||
{selected?.hint && (
|
||||
<p className="text-[10px] text-blue-600 leading-snug px-0.5">{selected.hint}</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -354,8 +422,11 @@ function StatusIcon({ status, isExisting, errorMsg }: { status: FileStatus; isEx
|
|||
|
||||
// ── Main component ─────────────────────────────────────────────────────────
|
||||
|
||||
export default function ContractorUploadModal({ deal, portfolioId, onClose, docStatusMap }: Props) {
|
||||
const measures = parseMeasures(deal.proposedMeasures);
|
||||
export default function ContractorUploadModal({ deal, portfolioId, onClose, docStatusMap, approvedMeasures }: Props) {
|
||||
// Use approved measures when available; fall back to all proposed measures
|
||||
const measures = (approvedMeasures && approvedMeasures.length > 0)
|
||||
? approvedMeasures
|
||||
: parseMeasures(deal.proposedMeasures);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [queue, setQueue] = useState<FileEntry[]>([]);
|
||||
|
|
@ -708,63 +779,107 @@ export default function ContractorUploadModal({ deal, portfolioId, onClose, docS
|
|||
|
||||
{/* ── Phase 2: Classify ── */}
|
||||
{phase === "classify" && (() => {
|
||||
// Per-entry: look up what's already uploaded for that entry's measure
|
||||
const uprn = deal.uprn ?? null;
|
||||
const docStatus = uprn ? docStatusMap?.[uprn] : undefined;
|
||||
const measureProgressMap = new Map(
|
||||
(docStatus?.measureProgress ?? []).map((m) => [m.measureName, m]),
|
||||
);
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-4">
|
||||
{/* PAS guidance */}
|
||||
<PasGuidancePanel />
|
||||
|
||||
{/* Column headers */}
|
||||
<div className="grid grid-cols-[1fr_260px_180px] gap-2 px-1">
|
||||
<span className="text-[10px] font-semibold text-gray-400 uppercase tracking-wide">File</span>
|
||||
<span className="text-[10px] font-semibold text-gray-400 uppercase tracking-wide">Document Type <span className="text-red-400">*</span></span>
|
||||
<span className="text-[10px] font-semibold text-gray-400 uppercase tracking-wide">Measure</span>
|
||||
</div>
|
||||
|
||||
{classifiableEntries.map((entry) => {
|
||||
const entryMeasure = entry.measureName && entry.measureName !== "__none__" ? entry.measureName : null;
|
||||
const requiredDocs = entryMeasure ? getRequiredDocs(entryMeasure) : undefined;
|
||||
const uploadedDocs = entryMeasure ? (measureProgressMap.get(entryMeasure)?.uploaded ?? []) : undefined;
|
||||
return (
|
||||
<div key={entry.id} className="grid grid-cols-[1fr_260px_180px] gap-2 items-center px-1">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<StatusIcon status={entry.status} isExisting={!!entry.existingS3Key} />
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-medium text-gray-700 truncate">{entry.displayName}</p>
|
||||
{entry.displaySize && <p className="text-[10px] text-gray-400">{entry.displaySize}</p>}
|
||||
{entry.existingS3Key && <p className="text-[10px] text-amber-500">Previously uploaded</p>}
|
||||
</div>
|
||||
{/* Measure context banner */}
|
||||
{selectedMeasure && (
|
||||
<div className="flex items-center justify-between gap-2 px-3 py-2 rounded-lg bg-brandlightblue/20 border border-brandblue/20">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-semibold text-brandblue">{selectedMeasure}</span>
|
||||
{(() => {
|
||||
const mp = measureProgressMap.get(selectedMeasure);
|
||||
if (!mp) return null;
|
||||
return (
|
||||
<span className={`text-[10px] font-medium px-1.5 py-0.5 rounded-full border ${
|
||||
mp.isComplete ? "bg-emerald-50 text-emerald-700 border-emerald-200" : "bg-amber-50 text-amber-700 border-amber-200"
|
||||
}`}>
|
||||
{mp.uploadedCount}/{mp.requiredCount} docs uploaded
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
<DocTypeSelect
|
||||
value={entry.docType}
|
||||
onChange={(v) => updateEntryField(entry.id, "docType", v)}
|
||||
showHint
|
||||
requiredDocs={requiredDocs}
|
||||
uploadedDocs={uploadedDocs}
|
||||
/>
|
||||
{measures.length > 0 ? (
|
||||
<Select value={entry.measureName || "__none__"} onValueChange={(v) => updateEntryField(entry.id, "measureName", v === "__none__" ? "" : v)}>
|
||||
<SelectTrigger className="h-8 text-xs w-full">
|
||||
<SelectValue placeholder="—" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__" className="text-xs text-gray-400">— None —</SelectItem>
|
||||
{measures.map((m) => (
|
||||
<SelectItem key={m} value={m} className="text-xs">{m}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<span className="text-xs text-gray-300">—</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPhase("measure-select")}
|
||||
className="text-[10px] text-brandblue/60 hover:text-brandblue underline underline-offset-2"
|
||||
>
|
||||
Change
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
)}
|
||||
|
||||
{/* File list with classification */}
|
||||
<div className="space-y-3">
|
||||
{classifiableEntries.map((entry) => {
|
||||
const entryMeasure = entry.measureName && entry.measureName !== "__none__" ? entry.measureName : null;
|
||||
const requiredDocs = entryMeasure ? getRequiredDocs(entryMeasure) : null;
|
||||
const uploadedDocs = entryMeasure ? (measureProgressMap.get(entryMeasure)?.uploaded ?? []) : [];
|
||||
|
||||
return (
|
||||
<div key={entry.id} className="rounded-lg border border-gray-100 bg-gray-50/50 p-3 space-y-2.5">
|
||||
{/* File info row */}
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<StatusIcon status={entry.status} isExisting={!!entry.existingS3Key} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-medium text-gray-700 truncate">{entry.displayName}</p>
|
||||
<p className="text-[10px] text-gray-400">
|
||||
{entry.existingS3Key ? "Previously uploaded · " : ""}
|
||||
{entry.displaySize ?? ""}
|
||||
{entryMeasure && !selectedMeasure && (
|
||||
<span className="ml-1 text-brandblue/70">{entryMeasure}</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{/* Measure selector — only shown if no pre-selected measure */}
|
||||
{!selectedMeasure && measures.length > 0 && (
|
||||
<Select
|
||||
value={entry.measureName || "__none__"}
|
||||
onValueChange={(v) => updateEntryField(entry.id, "measureName", v === "__none__" ? "" : v)}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-[10px] w-36 shrink-0">
|
||||
<SelectValue placeholder="Measure…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__" className="text-xs text-gray-400">— None —</SelectItem>
|
||||
{measures.map((m) => (
|
||||
<SelectItem key={m} value={m} className="text-xs">{m}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Doc type selector */}
|
||||
<div>
|
||||
<p className="text-[10px] font-semibold text-gray-400 uppercase tracking-wide mb-1.5">
|
||||
Document type <span className="text-red-400">*</span>
|
||||
</p>
|
||||
{requiredDocs ? (
|
||||
<DocTypeButtonGrid
|
||||
value={entry.docType}
|
||||
onChange={(v) => updateEntryField(entry.id, "docType", v)}
|
||||
requiredDocs={requiredDocs}
|
||||
uploadedDocs={uploadedDocs}
|
||||
/>
|
||||
) : (
|
||||
<DocTypeSelect
|
||||
value={entry.docType}
|
||||
onChange={(v) => updateEntryField(entry.id, "docType", v)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Failed uploads (info only) */}
|
||||
{queue.filter((f) => f.status === "error").length > 0 && (
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ import {
|
|||
import { Search, ChevronLeft, ChevronRight, Download } from "lucide-react";
|
||||
import { createDocumentTableColumns } from "./DocumentTableColumns";
|
||||
import ContractorUploadModal from "./ContractorUploadModal";
|
||||
import type { ClassifiedDeal, DocStatusMap, PortfolioCapabilityType } from "./types";
|
||||
import type { ClassifiedDeal, DocStatusMap, PortfolioCapabilityType, ApprovalsByDeal } from "./types";
|
||||
|
||||
type RetroAssessmentFilter = "all" | "none" | "partial" | "complete";
|
||||
type InstallStatusFilter = "all" | "none" | "hasDocs" | "partial" | "complete";
|
||||
|
|
@ -40,6 +40,7 @@ interface DocumentTableProps {
|
|||
docStatusMap: DocStatusMap;
|
||||
portfolioId: string;
|
||||
userCapability: PortfolioCapabilityType;
|
||||
approvalsByDeal?: ApprovalsByDeal;
|
||||
}
|
||||
|
||||
function escapeCell(value: unknown): string {
|
||||
|
|
@ -53,7 +54,7 @@ function escapeCell(value: unknown): string {
|
|||
: str;
|
||||
}
|
||||
|
||||
export default function DocumentTable({ data, onOpenDrawer, docStatusMap, portfolioId, userCapability }: DocumentTableProps) {
|
||||
export default function DocumentTable({ data, onOpenDrawer, docStatusMap, portfolioId, userCapability, approvalsByDeal }: DocumentTableProps) {
|
||||
const [globalFilter, setGlobalFilter] = useState("");
|
||||
const [retroAssessmentFilter, setRetroAssessmentFilter] = useState<RetroAssessmentFilter>("all");
|
||||
const [installStatusFilter, setInstallStatusFilter] = useState<InstallStatusFilter>("all");
|
||||
|
|
@ -303,6 +304,7 @@ export default function DocumentTable({ data, onOpenDrawer, docStatusMap, portfo
|
|||
portfolioId={portfolioId}
|
||||
onClose={() => setUploadDeal(null)}
|
||||
docStatusMap={docStatusMap}
|
||||
approvedMeasures={approvalsByDeal?.[uploadDeal.dealId] ?? []}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -243,6 +243,7 @@ export default function LiveTracker({
|
|||
docStatusMap={docStatusMap}
|
||||
portfolioId={portfolioId}
|
||||
userCapability={userCapability}
|
||||
approvalsByDeal={approvalsByDeal}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
|
@ -376,6 +377,7 @@ export default function LiveTracker({
|
|||
uprn={drawerState.uprn}
|
||||
landlordPropertyId={drawerState.landlordPropertyId}
|
||||
dealname={drawerState.dealname}
|
||||
docStatus={drawerState.uprn ? docStatusMap[drawerState.uprn] : undefined}
|
||||
onClose={() =>
|
||||
setDrawerState({ open: false, uprn: null, landlordPropertyId: null, dealname: null })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ import {
|
|||
DrawerTitle,
|
||||
DrawerDescription,
|
||||
} from "@/app/shadcn_components/ui/drawer";
|
||||
import type { PropertyDocument } from "./types";
|
||||
import type { PropertyDocument, DocStatus } from "./types";
|
||||
import { EXPECTED_RETROFIT_ASSESSMENT_DOC_TYPES, SURVEY_ALL_DOC_TYPES } from "./types";
|
||||
|
||||
// Human-readable labels for all DB fileType enum values
|
||||
|
|
@ -86,11 +86,9 @@ function formatDate(iso: string): string {
|
|||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Individual document row
|
||||
// Reusable download button — encapsulates the presigned URL mutation
|
||||
// -----------------------------------------------------------------------
|
||||
function DocumentRow({ doc, showMeasure }: { doc: PropertyDocument; showMeasure?: boolean }) {
|
||||
const label = DOC_TYPE_LABELS[doc.docType] ?? doc.docType;
|
||||
|
||||
function DownloadDocButton({ doc }: { doc: PropertyDocument }) {
|
||||
const { mutate: download, isPending: signing } = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await fetch("/api/sign-document-url", {
|
||||
|
|
@ -107,6 +105,28 @@ function DocumentRow({ doc, showMeasure }: { doc: PropertyDocument; showMeasure?
|
|||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => download()}
|
||||
disabled={signing}
|
||||
className="shrink-0 inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-brandblue text-white text-xs font-medium hover:bg-brandblue/90 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
>
|
||||
{signing ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<FileDown className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{signing ? "Preparing…" : "Download"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Individual document row — used in retrofit section and install fallback
|
||||
// -----------------------------------------------------------------------
|
||||
function DocumentRow({ doc, showMeasure }: { doc: PropertyDocument; showMeasure?: boolean }) {
|
||||
const label = DOC_TYPE_LABELS[doc.docType] ?? doc.docType;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
layout
|
||||
|
|
@ -130,19 +150,7 @@ function DocumentRow({ doc, showMeasure }: { doc: PropertyDocument; showMeasure?
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: download button */}
|
||||
<button
|
||||
onClick={() => download()}
|
||||
disabled={signing}
|
||||
className="shrink-0 inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-brandblue text-white text-xs font-medium hover:bg-brandblue/90 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
>
|
||||
{signing ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<FileDown className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{signing ? "Preparing…" : "Download"}
|
||||
</button>
|
||||
<DownloadDocButton doc={doc} />
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
|
@ -155,6 +163,7 @@ interface PropertyDrawerProps {
|
|||
uprn: string | null;
|
||||
landlordPropertyId: string | null;
|
||||
dealname: string | null;
|
||||
docStatus?: DocStatus;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
|
|
@ -163,6 +172,7 @@ export default function PropertyDrawer({
|
|||
uprn,
|
||||
landlordPropertyId,
|
||||
dealname,
|
||||
docStatus,
|
||||
onClose,
|
||||
}: PropertyDrawerProps) {
|
||||
const canQuery = !!(uprn || landlordPropertyId);
|
||||
|
|
@ -361,13 +371,112 @@ export default function PropertyDrawer({
|
|||
key="install"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="space-y-2"
|
||||
className="space-y-3"
|
||||
>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-gray-400 px-0.5 flex items-center gap-1.5">
|
||||
<HardHat className="h-3.5 w-3.5" />
|
||||
Install Documents
|
||||
</h3>
|
||||
{installDocs.length > 0 ? (
|
||||
|
||||
{docStatus?.measureProgress && docStatus.measureProgress.length > 0 ? (
|
||||
// ── Per-measure checklist ──
|
||||
<div className="space-y-4">
|
||||
{docStatus.measureProgress.map((mp) => {
|
||||
const measureDocs = installDocs.filter((d) => d.measureName === mp.measureName);
|
||||
const uploadedTypeSet = new Set(measureDocs.map((d) => d.docType));
|
||||
const missingTypes = mp.required.filter((t) => !uploadedTypeSet.has(t));
|
||||
|
||||
return (
|
||||
<div key={mp.measureName} className="rounded-xl border border-gray-100 bg-gray-50/40 overflow-hidden">
|
||||
{/* Measure header */}
|
||||
<div className="flex items-center justify-between px-3 py-2.5 border-b border-gray-100 bg-white">
|
||||
<span className="text-xs font-semibold text-gray-800">{mp.measureName}</span>
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-medium border ${
|
||||
mp.isComplete
|
||||
? "bg-emerald-50 text-emerald-700 border-emerald-200"
|
||||
: mp.uploadedCount > 0
|
||||
? "bg-amber-50 text-amber-700 border-amber-200"
|
||||
: "bg-gray-100 text-gray-500 border-gray-200"
|
||||
}`}>
|
||||
{mp.uploadedCount} / {mp.requiredCount} docs
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="px-3 py-2.5 space-y-1.5">
|
||||
{/* Uploaded required docs */}
|
||||
{mp.uploaded.map((docType) => {
|
||||
const doc = measureDocs.find((d) => d.docType === docType);
|
||||
if (!doc) return null;
|
||||
return (
|
||||
<div key={docType} className="flex items-center justify-between gap-3 px-3 py-2 rounded-lg border border-emerald-100 bg-emerald-50/50">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className="shrink-0 w-5 h-5 rounded-full bg-emerald-100 border border-emerald-200 flex items-center justify-center">
|
||||
<svg className="h-3 w-3 text-emerald-600" viewBox="0 0 12 12" fill="none">
|
||||
<path d="M2 6l3 3 5-5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-medium text-gray-800 truncate">{DOC_TYPE_LABELS[docType] ?? docType}</p>
|
||||
<p className="text-[10px] text-gray-400">{formatDate(doc.s3UploadTimestamp)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<DownloadDocButton doc={doc} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Missing required docs */}
|
||||
{missingTypes.map((docType) => (
|
||||
<div key={docType} className="flex items-center gap-2.5 px-3 py-2 rounded-lg border border-dashed border-amber-200 bg-amber-50/30">
|
||||
<div className="shrink-0 w-5 h-5 rounded-full border-2 border-dashed border-amber-300 flex items-center justify-center">
|
||||
<FileX className="h-2.5 w-2.5 text-amber-400" />
|
||||
</div>
|
||||
<p className="text-xs text-amber-700 font-medium">{DOC_TYPE_LABELS[docType] ?? docType}</p>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Extra docs uploaded for this measure (not in required list) */}
|
||||
{measureDocs
|
||||
.filter((d) => !mp.required.includes(d.docType))
|
||||
.map((doc) => (
|
||||
<div key={doc.id} className="flex items-center justify-between gap-3 px-3 py-2 rounded-lg border border-gray-100 bg-white">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className="shrink-0 w-5 h-5 rounded-full bg-sky-50 border border-sky-200 flex items-center justify-center">
|
||||
<FileText className="h-3 w-3 text-sky-500" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-medium text-gray-800 truncate">{DOC_TYPE_LABELS[doc.docType] ?? doc.docType}</p>
|
||||
<p className="text-[10px] text-gray-400">{formatDate(doc.s3UploadTimestamp)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<DownloadDocButton doc={doc} />
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Unassigned / no-measure install docs */}
|
||||
{(() => {
|
||||
const knownMeasures = new Set(docStatus.measureProgress.map((m) => m.measureName));
|
||||
const unassigned = installDocs.filter(
|
||||
(d) => !d.measureName || !knownMeasures.has(d.measureName),
|
||||
);
|
||||
if (unassigned.length === 0) return null;
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<h4 className="text-[10px] font-semibold uppercase tracking-wide text-gray-400 px-0.5">Other</h4>
|
||||
{unassigned.map((doc) => (
|
||||
<DocumentRow key={doc.id} doc={doc} showMeasure />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
) : installDocs.length > 0 ? (
|
||||
// ── Fallback: flat list (no measure progress data) ──
|
||||
<div className="space-y-1.5">
|
||||
{installDocs.map((doc) => (
|
||||
<DocumentRow key={doc.id} doc={doc} showMeasure />
|
||||
|
|
|
|||
|
|
@ -255,13 +255,15 @@ export default async function LiveReportingPage(props: {
|
|||
docsByUprn.get(key)!.push({ fileType: row.fileType, measureName: row.measureName });
|
||||
}
|
||||
|
||||
// Build measures lookup from deals (uprn → proposed measure names)
|
||||
// Build measures lookup from deals (uprn → approved measures, falling back to proposed)
|
||||
const measuresByUprn = new Map<string, string[]>();
|
||||
for (const deal of deals) {
|
||||
if (deal.uprn) {
|
||||
const key = String(deal.uprn);
|
||||
const measures = (deal.proposedMeasures ?? "")
|
||||
.split(",").map((m: string) => m.trim()).filter(Boolean);
|
||||
const approved = approvalsByDeal[deal.dealId] ?? [];
|
||||
const measures = approved.length > 0
|
||||
? approved
|
||||
: (deal.proposedMeasures ?? "").split(",").map((m: string) => m.trim()).filter(Boolean);
|
||||
measuresByUprn.set(key, measures);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue