mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-08 11:37:25 +00:00
adding measure level tracking for uploaded docs
This commit is contained in:
parent
24edd2656b
commit
785c40f2d1
8 changed files with 351 additions and 27 deletions
|
|
@ -7,6 +7,26 @@ import { z } from "zod";
|
|||
import { getServerSession } from "next-auth";
|
||||
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
|
||||
import { syncContractorDocUploadToHubSpot } from "@/app/lib/hubspot/dealSync";
|
||||
import { getRequiredDocs } from "@/app/lib/measureDocumentRequirements";
|
||||
|
||||
function computeMeasureProgress(
|
||||
proposedMeasures: string[],
|
||||
dealDocs: { fileType: string | null; measureName: string | null }[],
|
||||
) {
|
||||
const installDocs = dealDocs.filter((d) => d.fileType !== null && d.measureName !== null);
|
||||
return proposedMeasures.map((measureName) => {
|
||||
const required = getRequiredDocs(measureName);
|
||||
const docsForMeasure = installDocs.filter((d) => d.measureName === measureName);
|
||||
const uploadedTypeSet = new Set(docsForMeasure.map((d) => d.fileType));
|
||||
const uploadedCount = required.filter((r) => uploadedTypeSet.has(r)).length;
|
||||
return {
|
||||
measureName,
|
||||
uploadedCount,
|
||||
requiredCount: required.length,
|
||||
isComplete: uploadedCount === required.length,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// POST — record a contractor install document in uploaded_files (fileType optional — can be classified later)
|
||||
export async function POST(req: NextRequest) {
|
||||
|
|
@ -85,6 +105,8 @@ export async function PATCH(req: NextRequest) {
|
|||
measureName: z.string().optional(),
|
||||
}),
|
||||
),
|
||||
hubspotDealId: z.string().optional(),
|
||||
proposedMeasures: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
let body: z.infer<typeof bodySchema>;
|
||||
|
|
@ -110,6 +132,17 @@ export async function PATCH(req: NextRequest) {
|
|||
.where(eq(uploadedFiles.id, BigInt(update.id)));
|
||||
}
|
||||
|
||||
// Sync per-measure progress to HubSpot after classification
|
||||
if (body.hubspotDealId && body.proposedMeasures && body.proposedMeasures.length > 0) {
|
||||
const dealDocs = await db
|
||||
.select({ fileType: uploadedFiles.fileType, measureName: uploadedFiles.measureName })
|
||||
.from(uploadedFiles)
|
||||
.where(eq(uploadedFiles.hubsotDealId, body.hubspotDealId));
|
||||
|
||||
const measureProgress = computeMeasureProgress(body.proposedMeasures, dealDocs);
|
||||
void syncContractorDocUploadToHubSpot({ hubspotDealId: body.hubspotDealId, measureProgress });
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (err) {
|
||||
console.error("PATCH /upload/contractor-install error:", err);
|
||||
|
|
|
|||
|
|
@ -71,16 +71,37 @@ export async function syncRemovalRequestToHubSpot(params: {
|
|||
}
|
||||
}
|
||||
|
||||
type MeasureUploadProgress = {
|
||||
measureName: string;
|
||||
uploadedCount: number;
|
||||
requiredCount: number;
|
||||
isComplete: boolean;
|
||||
};
|
||||
|
||||
export async function syncContractorDocUploadToHubSpot(params: {
|
||||
hubspotDealId: string;
|
||||
measureProgress?: MeasureUploadProgress[];
|
||||
}): Promise<void> {
|
||||
let log: string;
|
||||
if (params.measureProgress && params.measureProgress.length > 0) {
|
||||
log = params.measureProgress
|
||||
.map((m) => {
|
||||
if (m.isComplete) return `${m.measureName}: Complete (${m.uploadedCount}/${m.requiredCount} docs)`;
|
||||
if (m.uploadedCount > 0) return `${m.measureName}: In Progress (${m.uploadedCount}/${m.requiredCount} docs)`;
|
||||
return `${m.measureName}: Not Started (0/${m.requiredCount} docs)`;
|
||||
})
|
||||
.join(" | ");
|
||||
} else {
|
||||
log = "Documents available - uploaded by contractor";
|
||||
}
|
||||
|
||||
const maxAttempts = 3;
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
try {
|
||||
const client = getHubSpotClient();
|
||||
await client.crm.deals.basicApi.update(params.hubspotDealId, {
|
||||
properties: {
|
||||
contractor_document_upload_log: "Documents available - uploaded by contractor",
|
||||
contractor_document_upload_log: log,
|
||||
},
|
||||
});
|
||||
return;
|
||||
|
|
|
|||
81
src/app/lib/measureDocumentRequirements.ts
Normal file
81
src/app/lib/measureDocumentRequirements.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
/**
|
||||
* Measure Document Requirements
|
||||
* Maps HubSpot measure names to the required installer document types (fileType enum values).
|
||||
* Used to compute per-measure upload completion and guide contractors in the upload modal.
|
||||
*/
|
||||
|
||||
// Required for every measure
|
||||
const BASE_DOCS = [
|
||||
"pre_photo",
|
||||
"mid_photo",
|
||||
"post_photo",
|
||||
"pre_installation_building_inspection",
|
||||
"claim_of_compliance",
|
||||
"insurance_guarantee",
|
||||
"workmanship_warranty",
|
||||
] as const;
|
||||
|
||||
// MCS-accredited measures require MCS certification in addition to base docs
|
||||
const MCS_EXTRA = ["mcs_compliance_certificate"] as const;
|
||||
|
||||
export const MEASURE_DOC_REQUIREMENTS: Record<string, string[]> = {
|
||||
ASHP: [...BASE_DOCS, ...MCS_EXTRA, "commissioning_records"],
|
||||
"Solar PV": [...BASE_DOCS, ...MCS_EXTRA, "g98_notification"],
|
||||
DMevs: [
|
||||
...BASE_DOCS,
|
||||
"dmev_photos",
|
||||
"anemometer_readings",
|
||||
"commissioning_records",
|
||||
"part_f_ventilation_document",
|
||||
"door_undercut_photos",
|
||||
"trickle_vent_photos",
|
||||
"ventilation_assessment_checklist",
|
||||
"minor_works_electrical_certificate",
|
||||
],
|
||||
"Loft insulation": [...BASE_DOCS, "loft_hatch_photo"],
|
||||
// All remaining measures require BASE_DOCS only:
|
||||
// CWI, EWI, IWI, "Flat roof", RIR, UFI, HW, Windows, "Ext. doors",
|
||||
// TRVs, "Heating controls", "New boiler", HHRSH, Battery, LEL,
|
||||
// "Listed building", "Removal 2nd heating", Others
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the required document types for a given measure name.
|
||||
* Falls back to BASE_DOCS for any measure not explicitly listed.
|
||||
*/
|
||||
export function getRequiredDocs(measureName: string): string[] {
|
||||
return MEASURE_DOC_REQUIREMENTS[measureName] ?? [...BASE_DOCS];
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-readable label for a fileType enum value.
|
||||
* Matches the labels used in ContractorUploadModal FILE_TYPE_OPTIONS.
|
||||
*/
|
||||
export const FILE_TYPE_LABELS: Record<string, string> = {
|
||||
pre_photo: "Pre-Install Photos",
|
||||
mid_photo: "Mid-Install Photos",
|
||||
post_photo: "Post-Install Photos",
|
||||
loft_hatch_photo: "Loft Hatch & Draft Excluder Photos",
|
||||
dmev_photos: "DMEV Photos (Wetrooms)",
|
||||
door_undercut_photos: "Door Undercut Photos",
|
||||
trickle_vent_photos: "Trickle Vent Photos",
|
||||
pre_installation_building_inspection: "PIBI / Tech Survey",
|
||||
point_of_work_risk_assessment: "Point of Work Risk Assessment",
|
||||
claim_of_compliance: "DOCC 2030 (Claim of Compliance)",
|
||||
mcs_compliance_certificate: "MCS Compliance Certificate",
|
||||
certificate_of_conformity: "Certificate of Conformity",
|
||||
minor_works_electrical_certificate: "Minor Works Electrical Certificate",
|
||||
trustmark_licence_numbers: "TrustMark Licence Numbers",
|
||||
operative_competency: "Operative Competency",
|
||||
ventilation_assessment_checklist: "Ventilation Assessment Checklist",
|
||||
anemometer_readings: "Anemometer Readings",
|
||||
commissioning_records: "Commissioning Records",
|
||||
part_f_ventilation_document: "Approved Document Part F",
|
||||
handover_pack: "Handover Pack",
|
||||
workmanship_warranty: "Workmanship Warranty",
|
||||
insurance_guarantee: "Insurance Backed Guarantee (IBG)",
|
||||
g98_notification: "G98 / G99 Notification",
|
||||
installer_qualifications: "Installer Qualifications",
|
||||
installer_feedback: "Installer Feedback",
|
||||
contractor_other: "Other",
|
||||
};
|
||||
|
|
@ -21,7 +21,8 @@ import {
|
|||
} from "@/app/shadcn_components/ui/select";
|
||||
import { CheckCircle2, XCircle, Upload, Loader2, Clock, ChevronDown, ChevronRight, Info } from "lucide-react";
|
||||
import { uploadFileToS3 } from "@/app/utils/s3";
|
||||
import type { ClassifiedDeal } from "./types";
|
||||
import type { ClassifiedDeal, DocStatusMap } from "./types";
|
||||
import { getRequiredDocs } from "@/app/lib/measureDocumentRequirements";
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -44,12 +45,13 @@ type FileEntry = {
|
|||
measureName: string;
|
||||
};
|
||||
|
||||
type Phase = "loading" | "upload" | "classify";
|
||||
type Phase = "loading" | "measure-select" | "upload" | "classify";
|
||||
|
||||
type Props = {
|
||||
deal: ClassifiedDeal;
|
||||
portfolioId: string;
|
||||
onClose: () => void;
|
||||
docStatusMap?: DocStatusMap;
|
||||
};
|
||||
|
||||
// ── Constants ─────────────────────────────────────────────────────────────
|
||||
|
|
@ -200,11 +202,13 @@ async function recordUpload(payload: {
|
|||
|
||||
async function saveClassifications(
|
||||
updates: { id: string; fileType: string; measureName?: string }[],
|
||||
hubspotDealId?: string,
|
||||
proposedMeasures?: string[],
|
||||
): Promise<void> {
|
||||
const res = await fetch("/api/upload/contractor-install", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ updates }),
|
||||
body: JSON.stringify({ updates, hubspotDealId, proposedMeasures }),
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to save classifications");
|
||||
}
|
||||
|
|
@ -257,8 +261,29 @@ function PasGuidancePanel() {
|
|||
|
||||
// ── DocType select ────────────────────────────────────────────────────────
|
||||
|
||||
function DocTypeSelect({ value, onChange, showHint = false }: { value: string; onChange: (v: string) => void; showHint?: boolean }) {
|
||||
function DocTypeSelect({
|
||||
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
|
||||
}) {
|
||||
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">
|
||||
|
|
@ -268,8 +293,32 @@ function DocTypeSelect({ value, onChange, showHint = false }: { value: string; o
|
|||
</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);
|
||||
const items = FILE_TYPE_OPTIONS.filter(
|
||||
(o) => o.group === group && !requiredSet.has(o.value),
|
||||
);
|
||||
if (!items.length) return null;
|
||||
return (
|
||||
<SelectGroup key={group}>
|
||||
|
|
@ -305,7 +354,7 @@ function StatusIcon({ status, isExisting, errorMsg }: { status: FileStatus; isEx
|
|||
|
||||
// ── Main component ─────────────────────────────────────────────────────────
|
||||
|
||||
export default function ContractorUploadModal({ deal, portfolioId, onClose }: Props) {
|
||||
export default function ContractorUploadModal({ deal, portfolioId, onClose, docStatusMap }: Props) {
|
||||
const measures = parseMeasures(deal.proposedMeasures);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
|
|
@ -314,6 +363,8 @@ export default function ContractorUploadModal({ deal, portfolioId, onClose }: Pr
|
|||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
// The measure selected in the measure-select phase (empty = "not measure-specific")
|
||||
const [selectedMeasure, setSelectedMeasure] = useState<string>("");
|
||||
|
||||
// ── Fetch existing unclassified files on mount ───────────────────────
|
||||
|
||||
|
|
@ -322,7 +373,7 @@ export default function ContractorUploadModal({ deal, portfolioId, onClose }: Pr
|
|||
const uprnParam = deal.uprn;
|
||||
const propIdParam = deal.landlordPropertyId;
|
||||
if (!uprnParam && !propIdParam) {
|
||||
setPhase("upload");
|
||||
setPhase(measures.length > 0 ? "measure-select" : "upload");
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -350,12 +401,14 @@ export default function ContractorUploadModal({ deal, portfolioId, onClose }: Pr
|
|||
}));
|
||||
setQueue(entries);
|
||||
setPhase("classify");
|
||||
} else if (measures.length > 0) {
|
||||
setPhase("measure-select");
|
||||
} else {
|
||||
setPhase("upload");
|
||||
}
|
||||
} catch {
|
||||
// If fetch fails, just proceed to upload phase
|
||||
setPhase("upload");
|
||||
// If fetch fails, just proceed to measure-select (or upload if no measures)
|
||||
setPhase(measures.length > 0 ? "measure-select" : "upload");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -373,7 +426,7 @@ export default function ContractorUploadModal({ deal, portfolioId, onClose }: Pr
|
|||
displaySize: formatSize(f.size),
|
||||
status: "queued",
|
||||
docType: "",
|
||||
measureName: measures[0] ?? "",
|
||||
measureName: selectedMeasure,
|
||||
}));
|
||||
setQueue((prev) => [...prev, ...newEntries]);
|
||||
}
|
||||
|
|
@ -473,6 +526,8 @@ export default function ContractorUploadModal({ deal, portfolioId, onClose }: Pr
|
|||
fileType: f.docType,
|
||||
measureName: (f.measureName && f.measureName !== "__none__") ? f.measureName : undefined,
|
||||
})),
|
||||
deal.dealId,
|
||||
measures.length > 0 ? measures : undefined,
|
||||
);
|
||||
onClose();
|
||||
} catch {
|
||||
|
|
@ -496,14 +551,24 @@ export default function ContractorUploadModal({ deal, portfolioId, onClose }: Pr
|
|||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{phase === "loading" ? "Loading…" :
|
||||
phase === "measure-select" ? "Select Measure" :
|
||||
phase === "upload" ? "Upload Documents" :
|
||||
"Classify Documents"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{phase === "loading" && "Checking for pending files…"}
|
||||
{phase === "measure-select" && (
|
||||
<>
|
||||
Which measure are you uploading documents for?{" "}
|
||||
<strong>{propertyLabel}</strong>
|
||||
</>
|
||||
)}
|
||||
{phase === "upload" && (
|
||||
<>
|
||||
Upload install documents for <strong>{propertyLabel}</strong>.
|
||||
{selectedMeasure
|
||||
? <>Uploading documents for <strong>{selectedMeasure}</strong> — <strong>{propertyLabel}</strong>.</>
|
||||
: <>Upload install documents for <strong>{propertyLabel}</strong>.</>
|
||||
}
|
||||
{existingCount > 0 && ` ${existingCount} file${existingCount !== 1 ? "s" : ""} are pending classification.`}
|
||||
</>
|
||||
)}
|
||||
|
|
@ -525,6 +590,56 @@ export default function ContractorUploadModal({ deal, portfolioId, onClose }: Pr
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Measure Select ── */}
|
||||
{phase === "measure-select" && (() => {
|
||||
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">
|
||||
<p className="text-xs text-gray-500">
|
||||
Select the measure you are uploading documents for. This helps track completion against required documents.
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||
{measures.map((measure) => {
|
||||
const progress = measureProgressMap.get(measure);
|
||||
const isComplete = progress?.isComplete ?? false;
|
||||
const uploaded = progress?.uploadedCount ?? 0;
|
||||
const required = progress?.requiredCount ?? getRequiredDocs(measure).length;
|
||||
return (
|
||||
<button
|
||||
key={measure}
|
||||
type="button"
|
||||
onClick={() => { setSelectedMeasure(measure); setPhase("upload"); }}
|
||||
className={`flex flex-col items-start gap-1 rounded-lg border px-3 py-2.5 text-left transition-colors hover:border-brandblue/40 hover:bg-brandlightblue/10 ${
|
||||
isComplete
|
||||
? "border-emerald-200 bg-emerald-50/60"
|
||||
: uploaded > 0
|
||||
? "border-amber-200 bg-amber-50/50"
|
||||
: "border-gray-200 bg-white"
|
||||
}`}
|
||||
>
|
||||
<span className="text-xs font-semibold text-gray-800 leading-tight">{measure}</span>
|
||||
<span className={`text-[10px] font-medium ${isComplete ? "text-emerald-600" : uploaded > 0 ? "text-amber-600" : "text-gray-400"}`}>
|
||||
{isComplete ? "✓ Complete" : `${uploaded} / ${required} docs`}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setSelectedMeasure(""); setPhase("upload"); }}
|
||||
className="text-xs text-gray-400 hover:text-gray-600 underline underline-offset-2 mt-1"
|
||||
>
|
||||
Not measure-specific / other
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* ── Phase 1: Upload ── */}
|
||||
{phase === "upload" && (
|
||||
<>
|
||||
|
|
@ -592,7 +707,14 @@ export default function ContractorUploadModal({ deal, portfolioId, onClose }: Pr
|
|||
)}
|
||||
|
||||
{/* ── Phase 2: Classify ── */}
|
||||
{phase === "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">
|
||||
{/* PAS guidance */}
|
||||
<PasGuidancePanel />
|
||||
|
|
@ -604,7 +726,11 @@ export default function ContractorUploadModal({ deal, portfolioId, onClose }: Pr
|
|||
<span className="text-[10px] font-semibold text-gray-400 uppercase tracking-wide">Measure</span>
|
||||
</div>
|
||||
|
||||
{classifiableEntries.map((entry) => (
|
||||
{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} />
|
||||
|
|
@ -614,7 +740,13 @@ export default function ContractorUploadModal({ deal, portfolioId, onClose }: Pr
|
|||
{entry.existingS3Key && <p className="text-[10px] text-amber-500">Previously uploaded</p>}
|
||||
</div>
|
||||
</div>
|
||||
<DocTypeSelect value={entry.docType} onChange={(v) => updateEntryField(entry.id, "docType", v)} showHint />
|
||||
<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">
|
||||
|
|
@ -631,7 +763,8 @@ export default function ContractorUploadModal({ deal, portfolioId, onClose }: Pr
|
|||
<span className="text-xs text-gray-300">—</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Failed uploads (info only) */}
|
||||
{queue.filter((f) => f.status === "error").length > 0 && (
|
||||
|
|
@ -651,7 +784,8 @@ export default function ContractorUploadModal({ deal, portfolioId, onClose }: Pr
|
|||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="pt-2 border-t border-gray-100 shrink-0">
|
||||
|
|
@ -659,6 +793,10 @@ export default function ContractorUploadModal({ deal, portfolioId, onClose }: Pr
|
|||
<Button variant="secondary" onClick={onClose}>Cancel</Button>
|
||||
)}
|
||||
|
||||
{phase === "measure-select" && (
|
||||
<Button variant="secondary" onClick={onClose}>Cancel</Button>
|
||||
)}
|
||||
|
||||
{phase === "upload" && (
|
||||
<>
|
||||
<Button variant="secondary" onClick={onClose} disabled={isUploading}>Cancel</Button>
|
||||
|
|
|
|||
|
|
@ -302,6 +302,7 @@ export default function DocumentTable({ data, onOpenDrawer, docStatusMap, portfo
|
|||
deal={uploadDeal}
|
||||
portfolioId={portfolioId}
|
||||
onClose={() => setUploadDeal(null)}
|
||||
docStatusMap={docStatusMap}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -49,19 +49,38 @@ function RetroAssessmentBadge({ status }: { status: DocStatus | undefined }) {
|
|||
|
||||
function InstallDocsBadge({ status }: { status: DocStatus | undefined }) {
|
||||
const installStatus = status?.installStatus ?? "none";
|
||||
const measureProgress = status?.measureProgress ?? [];
|
||||
|
||||
// Build a tooltip showing per-measure doc counts (e.g. "ASHP: 5/9, CWI: 7/7")
|
||||
const tooltip =
|
||||
measureProgress.length > 0
|
||||
? measureProgress
|
||||
.map((m) => `${m.measureName}: ${m.uploadedCount}/${m.requiredCount}`)
|
||||
.join(" | ")
|
||||
: undefined;
|
||||
|
||||
if (installStatus === "all") {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border whitespace-nowrap bg-emerald-50 text-emerald-700 border-emerald-200">
|
||||
<span
|
||||
title={tooltip}
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border whitespace-nowrap bg-emerald-50 text-emerald-700 border-emerald-200"
|
||||
>
|
||||
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||
All Measures
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (installStatus === "partial") {
|
||||
const completedCount = measureProgress.filter((m) => m.isComplete).length;
|
||||
const totalCount = measureProgress.length;
|
||||
const label = totalCount > 0 ? `${completedCount} / ${totalCount} measures` : "Some Measures";
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border whitespace-nowrap bg-amber-50 text-amber-700 border-amber-200">
|
||||
<span
|
||||
title={tooltip}
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border whitespace-nowrap bg-amber-50 text-amber-700 border-amber-200"
|
||||
>
|
||||
<AlertCircle className="h-3.5 w-3.5" />
|
||||
Some Measures
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,8 +13,9 @@ import { portfolioCapabilities, portfolioUsers } from "@/app/db/schema/portfolio
|
|||
import { dealMeasureApprovals } from "@/app/db/schema/approvals";
|
||||
import { propertyRemovalRequests } from "@/app/db/schema/removal_requests";
|
||||
import { user as userTable } from "@/app/db/schema/users";
|
||||
import type { HubspotDeal, DocStatusMap, DocStatus, PortfolioCapabilityType, ApprovalsByDeal, RemovalStatusByDeal, EffectiveRemovalState } from "./types";
|
||||
import type { HubspotDeal, DocStatusMap, DocStatus, MeasureDocProgress, PortfolioCapabilityType, ApprovalsByDeal, RemovalStatusByDeal, EffectiveRemovalState } from "./types";
|
||||
import { EXPECTED_RETROFIT_ASSESSMENT_DOC_TYPES, SURVEY_ALL_DOC_TYPES } from "./types";
|
||||
import { getRequiredDocs } from "@/app/lib/measureDocumentRequirements";
|
||||
import type { InferSelectModel } from "drizzle-orm";
|
||||
import { Card, CardContent } from "@/app/shadcn_components/ui/card";
|
||||
import { Building2 } from "lucide-react";
|
||||
|
|
@ -271,15 +272,33 @@ export default async function LiveReportingPage(props: {
|
|||
const surveyTypeSet = new Set(surveyDocs.map((d) => d.fileType));
|
||||
|
||||
const measures = measuresByUprn.get(uprn) ?? [];
|
||||
|
||||
// Compute per-measure document progress against the requirements matrix
|
||||
const measureProgress: MeasureDocProgress[] = measures.map((measureName) => {
|
||||
const required = getRequiredDocs(measureName);
|
||||
const docsForMeasure = installDocs.filter((d) => d.measureName === measureName);
|
||||
const uploadedTypeSet = new Set(docsForMeasure.map((d) => d.fileType));
|
||||
const uploaded = required.filter((r) => uploadedTypeSet.has(r));
|
||||
return {
|
||||
measureName,
|
||||
required,
|
||||
uploaded,
|
||||
isComplete: uploaded.length === required.length,
|
||||
uploadedCount: uploaded.length,
|
||||
requiredCount: required.length,
|
||||
};
|
||||
});
|
||||
|
||||
let installStatus: DocStatus["installStatus"] = "none";
|
||||
if (installDocs.length > 0) {
|
||||
if (measures.length === 0) {
|
||||
installStatus = "hasDocs";
|
||||
} else {
|
||||
const measuresWithDocs = new Set(
|
||||
installDocs.map((d) => d.measureName).filter(Boolean),
|
||||
);
|
||||
installStatus = measures.every((m) => measuresWithDocs.has(m)) ? "all" : "partial";
|
||||
installStatus = measureProgress.every((m) => m.isComplete)
|
||||
? "all"
|
||||
: measureProgress.some((m) => m.uploadedCount > 0)
|
||||
? "partial"
|
||||
: "none";
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -289,6 +308,7 @@ export default async function LiveReportingPage(props: {
|
|||
isSurveyComplete: EXPECTED_RETROFIT_ASSESSMENT_DOC_TYPES.every((t) => surveyTypeSet.has(t)),
|
||||
hasInstallDocs: installDocs.length > 0,
|
||||
installStatus,
|
||||
measureProgress,
|
||||
};
|
||||
docStatusMap[uprn] = status;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -250,6 +250,16 @@ export const SURVEY_ALL_DOC_TYPES = new Set<string>([
|
|||
"ecmk_survey_xml",
|
||||
]);
|
||||
|
||||
// Per-measure document upload progress
|
||||
export type MeasureDocProgress = {
|
||||
measureName: string;
|
||||
required: string[]; // required fileType values for this measure
|
||||
uploaded: string[]; // required fileType values that have been uploaded
|
||||
isComplete: boolean;
|
||||
uploadedCount: number;
|
||||
requiredCount: number;
|
||||
};
|
||||
|
||||
export type DocStatus = {
|
||||
// Retrofit assessment docs
|
||||
presentSurveyTypes: string[];
|
||||
|
|
@ -258,10 +268,11 @@ export type DocStatus = {
|
|||
// Install docs
|
||||
hasInstallDocs: boolean;
|
||||
installStatus: "none" | "partial" | "hasDocs" | "all";
|
||||
// "all" = install docs exist for every proposed measure
|
||||
// "partial" = some (but not all) proposed measures have docs
|
||||
// "all" = all required docs uploaded for every proposed measure
|
||||
// "partial" = some (but not all) proposed measures have complete docs
|
||||
// "hasDocs" = has install docs but no measures defined on the deal
|
||||
// "none" = no install docs at all
|
||||
measureProgress: MeasureDocProgress[]; // one entry per proposed measure
|
||||
};
|
||||
|
||||
export type DocStatusMap = Record<string, DocStatus>; // keyed by UPRN string
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue