diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/ContractorUploadModal.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/ContractorUploadModal.tsx index a7dd151a..152b80ab 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/ContractorUploadModal.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/ContractorUploadModal.tsx @@ -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 && ( - - - - )} {label} + {uploadedCount > 0 && !isSelected && ( + + {uploadedCount} + + )} ); })} @@ -432,6 +436,9 @@ export default function ContractorUploadModal({ deal, portfolioId, onClose, docS const [saveError, setSaveError] = useState(null); // The measure selected in the measure-select phase (empty = "not measure-specific") const [selectedMeasure, setSelectedMeasure] = useState(""); + // Bulk classify state + const [selectedIds, setSelectedIds] = useState>(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 )} + {/* Select-all row */} + {classifiableEntries.length > 0 && ( +
+ 0 && selectedIds.size === selectAllUnclassified(classifiableEntries).size} + onChange={() => { + const allUnclassified = selectAllUnclassified(classifiableEntries); + setSelectedIds(allUnclassified.size === selectedIds.size ? new Set() : allUnclassified); + }} + /> + +
+ )} + {/* File list with classification */}
{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 ( -
+
{/* File info row */}
+ toggleSelectId(entry.id)} + />

{entry.displayName}

@@ -897,6 +951,51 @@ export default function ContractorUploadModal({ deal, portfolioId, onClose, docS })()}
+ {/* ── Bulk classify toolbar — shown when files are selected ── */} + {phase === "classify" && selectedIds.size > 0 && ( +
+ + {selectedIds.size} selected + + + Classify as: + + + +
+ )} + {phase === "loading" && ( @@ -930,17 +1029,24 @@ export default function ContractorUploadModal({ deal, portfolioId, onClose, docS - + {unclassifiedCount > 0 && ( +

+ {unclassifiedCount} unclassified — will stay in your queue +

)} - +
)} diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/classifyPhase.test.ts b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/classifyPhase.test.ts new file mode 100644 index 00000000..00baad73 --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/classifyPhase.test.ts @@ -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 { + 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); + }); +}); diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/classifyPhase.ts b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/classifyPhase.ts new file mode 100644 index 00000000..fc18d04e --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/classifyPhase.ts @@ -0,0 +1,33 @@ +export type ClassifyEntry = { + id: string; + docType: string; + status: "queued" | "uploading" | "done" | "error"; +}; + +export function applyBulkDocType( + entries: T[], + selectedIds: Set, + docType: string, +): T[] { + return entries.map((e) => + selectedIds.has(e.id) && e.status === "done" ? { ...e, docType } : e, + ); +} + +export function selectAllUnclassified(entries: ClassifyEntry[]): Set { + 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; +}