mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-30 12:55:02 +00:00
Updating UI for document upload to improve confusing categorisation
Some checks failed
Test Suite / unit-tests (push) Has been cancelled
Some checks failed
Test Suite / unit-tests (push) Has been cancelled
This commit is contained in:
parent
9654c2cfd7
commit
f6b9f9f65c
3 changed files with 307 additions and 25 deletions
|
|
@ -24,6 +24,13 @@ import { uploadFileToS3 } from "@/app/utils/s3";
|
|||
import type { ClassifiedDeal, DocStatusMap } from "./types";
|
||||
import { getRequiredDocs } from "@/app/lib/measureDocumentRequirements";
|
||||
import { parseMeasures } from "@/app/lib/parseMeasures";
|
||||
import {
|
||||
applyBulkDocType,
|
||||
selectAllUnclassified,
|
||||
getClassifiedCount,
|
||||
getUnclassifiedIds,
|
||||
uploadedCountForType,
|
||||
} from "./classifyPhase";
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -272,7 +279,6 @@ function DocTypeButtonGrid({
|
|||
uploadedDocs: string[];
|
||||
}) {
|
||||
const [showOther, setShowOther] = useState(false);
|
||||
const uploadedSet = new Set(uploadedDocs);
|
||||
const requiredSet = new Set(requiredDocs);
|
||||
const isOtherSelected = value !== "" && !requiredSet.has(value);
|
||||
|
||||
|
|
@ -283,7 +289,7 @@ function DocTypeButtonGrid({
|
|||
{requiredDocs.map((docType) => {
|
||||
const option = FILE_TYPE_OPTIONS.find((o) => o.value === docType);
|
||||
const label = option?.label ?? docType;
|
||||
const alreadyUploaded = uploadedSet.has(docType);
|
||||
const uploadedCount = uploadedCountForType(uploadedDocs, docType);
|
||||
const isSelected = value === docType;
|
||||
|
||||
return (
|
||||
|
|
@ -291,21 +297,19 @@ function DocTypeButtonGrid({
|
|||
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 ${
|
||||
title={uploadedCount > 0 ? `${label} — ${uploadedCount} already uploaded` : label}
|
||||
className={`inline-flex items-center gap-1.5 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"
|
||||
: "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}
|
||||
{uploadedCount > 0 && !isSelected && (
|
||||
<span className="inline-flex items-center justify-center h-4 min-w-4 px-1 rounded-full bg-gray-100 text-gray-500 text-[10px] font-semibold leading-none">
|
||||
{uploadedCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
|
@ -432,6 +436,9 @@ export default function ContractorUploadModal({ deal, portfolioId, onClose, docS
|
|||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
// The measure selected in the measure-select phase (empty = "not measure-specific")
|
||||
const [selectedMeasure, setSelectedMeasure] = useState<string>("");
|
||||
// Bulk classify state
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [bulkDocType, setBulkDocType] = useState("");
|
||||
|
||||
// ── Fetch existing unclassified files on mount ───────────────────────
|
||||
|
||||
|
|
@ -581,14 +588,35 @@ export default function ContractorUploadModal({ deal, portfolioId, onClose, docS
|
|||
}
|
||||
|
||||
const classifiableEntries = queue.filter((f) => f.status === "done" && f.uploadedId);
|
||||
const allClassified = classifiableEntries.length > 0 && classifiableEntries.every((f) => f.docType !== "");
|
||||
const classifiedCount = getClassifiedCount(classifiableEntries);
|
||||
const unclassifiedCount = getUnclassifiedIds(classifiableEntries).length;
|
||||
|
||||
function handleBulkApply() {
|
||||
if (!bulkDocType) return;
|
||||
setQueue((prev) => applyBulkDocType(prev, selectedIds, bulkDocType));
|
||||
setSelectedIds(new Set());
|
||||
setBulkDocType("");
|
||||
}
|
||||
|
||||
function toggleSelectId(id: string) {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function handleSelectAllUnclassified() {
|
||||
setSelectedIds(selectAllUnclassified(classifiableEntries));
|
||||
}
|
||||
|
||||
async function handleSaveClassifications() {
|
||||
setSaveError(null);
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const toSave = classifiableEntries.filter((f) => f.docType !== "");
|
||||
await saveClassifications(
|
||||
classifiableEntries.map((f) => ({
|
||||
toSave.map((f) => ({
|
||||
id: f.uploadedId!,
|
||||
fileType: f.docType,
|
||||
measureName: (f.measureName && f.measureName !== "__none__") ? f.measureName : undefined,
|
||||
|
|
@ -810,17 +838,43 @@ export default function ContractorUploadModal({ deal, portfolioId, onClose, docS
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Select-all row */}
|
||||
{classifiableEntries.length > 0 && (
|
||||
<div className="flex items-center gap-2 px-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="select-all-unclassified"
|
||||
className="h-4 w-4 rounded border-gray-300 text-brandblue accent-brandblue cursor-pointer"
|
||||
checked={selectedIds.size > 0 && selectedIds.size === selectAllUnclassified(classifiableEntries).size}
|
||||
onChange={() => {
|
||||
const allUnclassified = selectAllUnclassified(classifiableEntries);
|
||||
setSelectedIds(allUnclassified.size === selectedIds.size ? new Set() : allUnclassified);
|
||||
}}
|
||||
/>
|
||||
<label htmlFor="select-all-unclassified" className="text-xs text-gray-500 cursor-pointer select-none">
|
||||
Select all unclassified ({unclassifiedCount})
|
||||
</label>
|
||||
</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 ?? []) : [];
|
||||
const isChecked = selectedIds.has(entry.id);
|
||||
|
||||
return (
|
||||
<div key={entry.id} className="rounded-lg border border-gray-100 bg-gray-50/50 p-3 space-y-2.5">
|
||||
<div key={entry.id} className={`rounded-lg border bg-gray-50/50 p-3 space-y-2.5 transition-colors ${isChecked ? "border-brandblue/30 bg-brandlightblue/5" : "border-gray-100"}`}>
|
||||
{/* File info row */}
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 rounded border-gray-300 text-brandblue accent-brandblue cursor-pointer shrink-0"
|
||||
checked={isChecked}
|
||||
onChange={() => toggleSelectId(entry.id)}
|
||||
/>
|
||||
<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>
|
||||
|
|
@ -897,6 +951,51 @@ export default function ContractorUploadModal({ deal, portfolioId, onClose, docS
|
|||
})()}
|
||||
</div>
|
||||
|
||||
{/* ── Bulk classify toolbar — shown when files are selected ── */}
|
||||
{phase === "classify" && selectedIds.size > 0 && (
|
||||
<div className="shrink-0 flex items-center gap-2 px-4 py-2.5 bg-brandlightblue/10 border-t border-brandblue/20">
|
||||
<span className="text-xs font-semibold text-brandblue whitespace-nowrap">
|
||||
{selectedIds.size} selected
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">—</span>
|
||||
<span className="text-xs text-gray-600 whitespace-nowrap">Classify as:</span>
|
||||
<Select value={bulkDocType} onValueChange={setBulkDocType}>
|
||||
<SelectTrigger className="h-7 text-xs flex-1 max-w-56">
|
||||
<SelectValue placeholder="Choose type…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FILE_TYPE_GROUPS.map((group) => {
|
||||
const items = FILE_TYPE_OPTIONS.filter((o) => o.group === group);
|
||||
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>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-7 text-xs bg-brandblue text-white shrink-0"
|
||||
disabled={!bulkDocType}
|
||||
onClick={handleBulkApply}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-gray-400 hover:text-gray-600 shrink-0 ml-1"
|
||||
onClick={() => setSelectedIds(new Set())}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter className="pt-2 border-t border-gray-100 shrink-0">
|
||||
{phase === "loading" && (
|
||||
<Button variant="secondary" onClick={onClose}>Cancel</Button>
|
||||
|
|
@ -930,17 +1029,24 @@ export default function ContractorUploadModal({ deal, portfolioId, onClose, docS
|
|||
<Button variant="secondary" onClick={onClose} disabled={isSaving}>
|
||||
Skip for now
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSaveClassifications}
|
||||
disabled={!allClassified || isSaving}
|
||||
className="bg-brandblue text-white gap-1.5"
|
||||
>
|
||||
{isSaving ? (
|
||||
<><Loader2 className="h-3.5 w-3.5 animate-spin" /> Saving…</>
|
||||
) : (
|
||||
"Save Classifications →"
|
||||
<div className="flex flex-col items-end gap-0.5">
|
||||
<Button
|
||||
onClick={handleSaveClassifications}
|
||||
disabled={classifiedCount === 0 || isSaving}
|
||||
className="bg-brandblue text-white gap-1.5"
|
||||
>
|
||||
{isSaving ? (
|
||||
<><Loader2 className="h-3.5 w-3.5 animate-spin" /> Saving…</>
|
||||
) : (
|
||||
`Save ${classifiedCount} classified →`
|
||||
)}
|
||||
</Button>
|
||||
{unclassifiedCount > 0 && (
|
||||
<p className="text-[10px] text-gray-400">
|
||||
{unclassifiedCount} unclassified — will stay in your queue
|
||||
</p>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</DialogFooter>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,143 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
applyBulkDocType,
|
||||
selectAllUnclassified,
|
||||
getClassifiedCount,
|
||||
getUnclassifiedIds,
|
||||
uploadedCountForType,
|
||||
} from "./classifyPhase";
|
||||
import type { ClassifyEntry } from "./classifyPhase";
|
||||
|
||||
function makeEntry(overrides: Partial<ClassifyEntry> = {}): ClassifyEntry {
|
||||
return {
|
||||
id: "1",
|
||||
docType: "",
|
||||
status: "done",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ── applyBulkDocType ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("applyBulkDocType", () => {
|
||||
it("applies docType to selected entries", () => {
|
||||
const entries = [makeEntry({ id: "a" }), makeEntry({ id: "b" })];
|
||||
const result = applyBulkDocType(entries, new Set(["a"]), "pre_photo");
|
||||
expect(result.find((e) => e.id === "a")?.docType).toBe("pre_photo");
|
||||
});
|
||||
|
||||
it("leaves unselected entries unchanged", () => {
|
||||
const entries = [makeEntry({ id: "a" }), makeEntry({ id: "b", docType: "post_photo" })];
|
||||
const result = applyBulkDocType(entries, new Set(["a"]), "pre_photo");
|
||||
expect(result.find((e) => e.id === "b")?.docType).toBe("post_photo");
|
||||
});
|
||||
|
||||
it("overwrites an existing docType on selected entry", () => {
|
||||
const entries = [makeEntry({ id: "a", docType: "mid_photo" })];
|
||||
const result = applyBulkDocType(entries, new Set(["a"]), "pre_photo");
|
||||
expect(result.find((e) => e.id === "a")?.docType).toBe("pre_photo");
|
||||
});
|
||||
|
||||
it("skips non-done entries even if selected", () => {
|
||||
const entries = [makeEntry({ id: "a", status: "error" })];
|
||||
const result = applyBulkDocType(entries, new Set(["a"]), "pre_photo");
|
||||
expect(result.find((e) => e.id === "a")?.docType).toBe("");
|
||||
});
|
||||
|
||||
it("returns same length array", () => {
|
||||
const entries = [makeEntry({ id: "a" }), makeEntry({ id: "b" })];
|
||||
const result = applyBulkDocType(entries, new Set(["a", "b"]), "pre_photo");
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ── selectAllUnclassified ────────────────────────────────────────────────────
|
||||
|
||||
describe("selectAllUnclassified", () => {
|
||||
it("returns IDs of done entries with no docType", () => {
|
||||
const entries = [makeEntry({ id: "a" }), makeEntry({ id: "b" })];
|
||||
const ids = selectAllUnclassified(entries);
|
||||
expect(ids).toEqual(new Set(["a", "b"]));
|
||||
});
|
||||
|
||||
it("excludes already-classified entries", () => {
|
||||
const entries = [
|
||||
makeEntry({ id: "a", docType: "pre_photo" }),
|
||||
makeEntry({ id: "b" }),
|
||||
];
|
||||
const ids = selectAllUnclassified(entries);
|
||||
expect(ids).toEqual(new Set(["b"]));
|
||||
});
|
||||
|
||||
it("excludes non-done entries", () => {
|
||||
const entries = [
|
||||
makeEntry({ id: "a", status: "error" }),
|
||||
makeEntry({ id: "b" }),
|
||||
];
|
||||
const ids = selectAllUnclassified(entries);
|
||||
expect(ids).toEqual(new Set(["b"]));
|
||||
});
|
||||
|
||||
it("returns empty set when all classified", () => {
|
||||
const entries = [makeEntry({ id: "a", docType: "pre_photo" })];
|
||||
expect(selectAllUnclassified(entries)).toEqual(new Set());
|
||||
});
|
||||
});
|
||||
|
||||
// ── getClassifiedCount ───────────────────────────────────────────────────────
|
||||
|
||||
describe("getClassifiedCount", () => {
|
||||
it("counts done entries with a docType", () => {
|
||||
const entries = [
|
||||
makeEntry({ id: "a", docType: "pre_photo" }),
|
||||
makeEntry({ id: "b" }),
|
||||
];
|
||||
expect(getClassifiedCount(entries)).toBe(1);
|
||||
});
|
||||
|
||||
it("returns 0 when none classified", () => {
|
||||
expect(getClassifiedCount([makeEntry(), makeEntry({ id: "2" })])).toBe(0);
|
||||
});
|
||||
|
||||
it("excludes non-done entries from count", () => {
|
||||
const entries = [makeEntry({ id: "a", status: "error", docType: "pre_photo" })];
|
||||
expect(getClassifiedCount(entries)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── getUnclassifiedIds ───────────────────────────────────────────────────────
|
||||
|
||||
describe("getUnclassifiedIds", () => {
|
||||
it("returns IDs of done entries with empty docType", () => {
|
||||
const entries = [
|
||||
makeEntry({ id: "a" }),
|
||||
makeEntry({ id: "b", docType: "pre_photo" }),
|
||||
];
|
||||
expect(getUnclassifiedIds(entries)).toEqual(["a"]);
|
||||
});
|
||||
|
||||
it("returns empty array when all classified", () => {
|
||||
const entries = [makeEntry({ id: "a", docType: "pre_photo" })];
|
||||
expect(getUnclassifiedIds(entries)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── uploadedCountForType ─────────────────────────────────────────────────────
|
||||
|
||||
describe("uploadedCountForType", () => {
|
||||
it("returns 0 when docType not in uploaded list", () => {
|
||||
expect(uploadedCountForType(["post_photo"], "pre_photo")).toBe(0);
|
||||
});
|
||||
|
||||
it("counts occurrences of docType", () => {
|
||||
expect(uploadedCountForType(["pre_photo", "pre_photo", "post_photo"], "pre_photo")).toBe(2);
|
||||
});
|
||||
|
||||
it("returns 0 for empty list", () => {
|
||||
expect(uploadedCountForType([], "pre_photo")).toBe(0);
|
||||
});
|
||||
|
||||
it("exact match only — partial strings don't count", () => {
|
||||
expect(uploadedCountForType(["pre_photo_extra"], "pre_photo")).toBe(0);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
export type ClassifyEntry = {
|
||||
id: string;
|
||||
docType: string;
|
||||
status: "queued" | "uploading" | "done" | "error";
|
||||
};
|
||||
|
||||
export function applyBulkDocType<T extends ClassifyEntry>(
|
||||
entries: T[],
|
||||
selectedIds: Set<string>,
|
||||
docType: string,
|
||||
): T[] {
|
||||
return entries.map((e) =>
|
||||
selectedIds.has(e.id) && e.status === "done" ? { ...e, docType } : e,
|
||||
);
|
||||
}
|
||||
|
||||
export function selectAllUnclassified(entries: ClassifyEntry[]): Set<string> {
|
||||
return new Set(
|
||||
entries.filter((e) => e.status === "done" && e.docType === "").map((e) => e.id),
|
||||
);
|
||||
}
|
||||
|
||||
export function getClassifiedCount(entries: ClassifyEntry[]): number {
|
||||
return entries.filter((e) => e.status === "done" && e.docType !== "").length;
|
||||
}
|
||||
|
||||
export function getUnclassifiedIds(entries: ClassifyEntry[]): string[] {
|
||||
return entries.filter((e) => e.status === "done" && e.docType === "").map((e) => e.id);
|
||||
}
|
||||
|
||||
export function uploadedCountForType(uploadedDocs: string[], docType: string): number {
|
||||
return uploadedDocs.filter((d) => d === docType).length;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue