+
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
-
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;
+}