diff --git a/bulk-address-upload.md b/bulk-address-upload.md deleted file mode 100644 index 643a012..0000000 --- a/bulk-address-upload.md +++ /dev/null @@ -1,98 +0,0 @@ -# Bulk Address Upload — Implementation Tracker - -## Overview - -Upload CSV/XLSX to S3 (browser-direct via XHR with progress bar) → confirm in DB → redirect to upload list. -Portfolio shows all uploads ordered by date. User picks which to continue. - ---- - -## DB Migration (manual — do this first) - -```sql -CREATE TABLE bulk_address_uploads ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - portfolio_id TEXT NOT NULL, - user_id TEXT NOT NULL, - s3_bucket TEXT NOT NULL, - s3_key TEXT NOT NULL, - filename TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'ready_for_processing', - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); -``` - -Status values: `ready_for_processing` | `processing` | `complete` | `failed` - -- [ ] Migration applied to dev -- [ ] Migration applied to staging -- [ ] Migration applied to prod - ---- - -## Tasks - -### 1. Drizzle Schema -- [ ] Create `src/app/db/schema/bulk_address_uploads.ts` -- [ ] Import + spread into `src/app/db/db.ts` - -### 2. Confirm API Route -- [ ] Create `src/app/api/upload/bulk-addresses/confirm/route.ts` - - POST `{ fileKey, filename, portfolioId, userId }` - - Inserts into `bulk_address_uploads`, `s3Bucket` from `RETROFIT_PLAN_INPUT_BUCKET_NAME` env - - Returns `{ id, s3Key, s3Bucket, status }` - -### 3. List API Route -- [ ] Create `src/app/api/portfolio/[portfolioId]/bulk-uploads/route.ts` - - GET → all uploads for portfolio ordered by `created_at DESC` - - Returns array of upload records - -### 4. Modal — XHR Upload + Progress + Redirect to List -- [ ] Replace `fetch` PUT → `XMLHttpRequest` in `handleUpload` -- [ ] Add `progress: number | null` state -- [ ] Show progress bar in dropzone while uploading -- [ ] After XHR load: POST confirm → `router.push(/portfolio/[id]/bulk-upload)` - -### 5. Upload List Page -- [ ] Create `src/app/portfolio/[portfolioId]/bulk-upload/page.tsx` - - Server component - - List all uploads: filename, status badge, created date, "Continue →" link - - Empty state if none - - Each row links to `/bulk-upload/[uploadId]` - -### 6. Upload Detail Page -- [ ] Create `src/app/portfolio/[portfolioId]/bulk-upload/[uploadId]/page.tsx` - - Server component - - Shows: filename, `s3://bucket/key`, status, created date - - For now: "Your file is queued for processing" - ---- - -## Flow - -``` -User drops/clicks file - → validate (size, extension, headers) - → GET presigned URL (/api/upload/bulk-addresses) - → XHR PUT to S3 (progress bar shown) - → POST confirm (/api/upload/bulk-addresses/confirm) - → redirect to list (/portfolio/[id]/bulk-upload) - → list page (all uploads, status badges, click to continue) - → detail page (/portfolio/[id]/bulk-upload/[uploadId]) - → shows s3_uri + status -``` - ---- - -## Files Touched - -| File | Status | -|------|--------| -| `src/app/db/schema/bulk_address_uploads.ts` | not started | -| `src/app/db/db.ts` | not started | -| `src/app/api/upload/bulk-addresses/confirm/route.ts` | not started | -| `src/app/api/portfolio/[portfolioId]/bulk-uploads/route.ts` | not started | -| `src/app/components/portfolio/BulkUploadComingSoonModal.tsx` | not started | -| `src/app/portfolio/[portfolioId]/bulk-upload/page.tsx` | not started | -| `src/app/portfolio/[portfolioId]/bulk-upload/[uploadId]/page.tsx` | not started | diff --git a/src/app/api/live-tracking/property-documents/route.ts b/src/app/api/live-tracking/property-documents/route.ts index 0e73dc6..912dba8 100644 --- a/src/app/api/live-tracking/property-documents/route.ts +++ b/src/app/api/live-tracking/property-documents/route.ts @@ -29,7 +29,6 @@ export async function GET(req: Request) { s3FileBucket: uploadedFiles.s3FileBucket, s3UploadTimestamp: uploadedFiles.s3UploadTimestamp, fileType: uploadedFiles.fileType, - source: uploadedFiles.source, uprn: uploadedFiles.uprn, landlordPropertyId: uploadedFiles.landlordPropertyId, }) @@ -40,8 +39,7 @@ export async function GET(req: Request) { id: String(row.id), s3FileKey: row.s3FileKey, s3FileBucket: row.s3FileBucket, - docType: row.fileType ?? null, - source: row.source ?? null, + docType: row.fileType ?? "unknown", s3UploadTimestamp: row.s3UploadTimestamp.toISOString(), uprn: row.uprn !== null ? String(row.uprn) : null, landlordPropertyId: row.landlordPropertyId, diff --git a/src/app/api/portfolio/[portfolioId]/approvals/route.ts b/src/app/api/portfolio/[portfolioId]/approvals/route.ts deleted file mode 100644 index c4b3933..0000000 --- a/src/app/api/portfolio/[portfolioId]/approvals/route.ts +++ /dev/null @@ -1,215 +0,0 @@ -import { db } from "@/app/db/db"; -import { NextRequest, NextResponse } from "next/server"; -import { - dealMeasureApprovals, - dealMeasureApprovalEvents, -} from "@/app/db/schema/approvals"; -import { portfolioCapabilities } from "@/app/db/schema/portfolio"; -import { user } from "@/app/db/schema/users"; -import { and, eq, inArray, sql } from "drizzle-orm"; -import { z } from "zod"; -import { getServerSession } from "next-auth"; -import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions"; - -async function getRequestingUserId(email: string): Promise { - const rows = await db - .select({ id: user.id }) - .from(user) - .where(eq(user.email, email)) - .limit(1); - return rows[0]?.id ?? null; -} - -async function hasApproverCapability( - portfolioId: bigint, - userId: bigint, -): Promise { - const rows = await db - .select({ id: portfolioCapabilities.id }) - .from(portfolioCapabilities) - .where( - and( - eq(portfolioCapabilities.portfolioId, portfolioId), - eq(portfolioCapabilities.userId, userId), - eq(portfolioCapabilities.capability, "approver"), - ), - ) - .limit(1); - return rows.length > 0; -} - -// GET — return currently approved measures per deal, and optionally the audit event log -// Query params: -// dealIds comma-separated HubSpot deal IDs (required) -// include "events" to also return the audit log -export async function GET( - req: NextRequest, - props: { params: Promise<{ portfolioId: string }> }, -) { - const url = new URL(req.url); - const dealIdsParam = url.searchParams.get("dealIds"); - const includeEvents = url.searchParams.get("include") === "events"; - - if (!dealIdsParam) { - return NextResponse.json(includeEvents ? { approved: {}, events: [] } : {}); - } - - const dealIds = dealIdsParam.split(",").filter(Boolean); - if (dealIds.length === 0) { - return NextResponse.json(includeEvents ? { approved: {}, events: [] } : {}); - } - - try { - // Current approved measures - const approvalRows = await db - .select({ - hubspotDealId: dealMeasureApprovals.hubspotDealId, - measureName: dealMeasureApprovals.measureName, - approvedByEmail: user.email, - approvedByName: user.firstName, - approvedAt: dealMeasureApprovals.approvedAt, - }) - .from(dealMeasureApprovals) - .leftJoin(user, eq(user.id, dealMeasureApprovals.approvedBy)) - .where( - and( - inArray(dealMeasureApprovals.hubspotDealId, dealIds), - eq(dealMeasureApprovals.isApproved, true), - ), - ); - - const approved: Record = {}; - for (const row of approvalRows) { - (approved[row.hubspotDealId] ??= []).push(row.measureName); - } - - if (!includeEvents) { - return NextResponse.json(approved); - } - - // Audit event log - const eventRows = await db - .select({ - id: dealMeasureApprovalEvents.id, - hubspotDealId: dealMeasureApprovalEvents.hubspotDealId, - measureName: dealMeasureApprovalEvents.measureName, - action: dealMeasureApprovalEvents.action, - actedByEmail: user.email, - actedByName: user.firstName, - actedAt: dealMeasureApprovalEvents.actedAt, - }) - .from(dealMeasureApprovalEvents) - .leftJoin(user, eq(user.id, dealMeasureApprovalEvents.actedBy)) - .where(inArray(dealMeasureApprovalEvents.hubspotDealId, dealIds)) - .orderBy(dealMeasureApprovalEvents.actedAt); - - const events = eventRows.map((e) => ({ - id: e.id.toString(), - hubspotDealId: e.hubspotDealId, - measureName: e.measureName, - action: e.action, - actedByEmail: e.actedByEmail ?? "", - actedByName: e.actedByName ?? null, - actedAt: e.actedAt.toISOString(), - })); - - return NextResponse.json({ approved, events }); - } catch (err) { - console.error("GET /approvals error:", err); - return NextResponse.json( - { error: "Failed to fetch approvals" }, - { status: 500 }, - ); - } -} - -// POST — apply explicit approve/unapprove changes, updating current state + audit log -// Body: { changes: [{ hubspotDealId, measureName, approved: boolean }] } -export async function POST( - req: NextRequest, - props: { params: Promise<{ portfolioId: string }> }, -) { - const session = await getServerSession(AuthOptions); - if (!session?.user?.email) { - return NextResponse.json({ error: "Unauthorised" }, { status: 401 }); - } - - const { portfolioId } = await props.params; - const pId = BigInt(portfolioId); - - const userId = await getRequestingUserId(session.user.email); - if (!userId) { - return NextResponse.json({ error: "User not found" }, { status: 404 }); - } - - const isApprover = await hasApproverCapability(pId, userId); - if (!isApprover) { - return NextResponse.json({ error: "Forbidden" }, { status: 403 }); - } - - const bodySchema = z.object({ - changes: z.array( - z.object({ - hubspotDealId: z.string(), - measureName: z.string(), - approved: z.boolean(), - }), - ), - }); - - let body: z.infer; - try { - body = bodySchema.parse(await req.json()); - } catch { - return NextResponse.json({ error: "Invalid body" }, { status: 400 }); - } - - if (body.changes.length === 0) { - return NextResponse.json({ success: true }); - } - - try { - const now = new Date(); - - for (const change of body.changes) { - // 1. Upsert current state - await db - .insert(dealMeasureApprovals) - .values({ - hubspotDealId: change.hubspotDealId, - measureName: change.measureName, - isApproved: change.approved, - approvedBy: userId, - approvedAt: now, - }) - .onConflictDoUpdate({ - target: [ - dealMeasureApprovals.hubspotDealId, - dealMeasureApprovals.measureName, - ], - set: { - isApproved: change.approved, - approvedBy: userId, - approvedAt: now, - }, - }); - - // 2. Append to audit log - await db.insert(dealMeasureApprovalEvents).values({ - hubspotDealId: change.hubspotDealId, - measureName: change.measureName, - action: change.approved ? "approved" : "unapproved", - actedBy: userId, - actedAt: now, - }); - } - - return NextResponse.json({ success: true }); - } catch (err) { - console.error("POST /approvals error:", err); - return NextResponse.json( - { error: "Failed to save approvals" }, - { status: 500 }, - ); - } -} diff --git a/src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/onboard/route.ts b/src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/onboard/route.ts deleted file mode 100644 index 5a6d4b0..0000000 --- a/src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/onboard/route.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { db } from "@/app/db/db"; -import { bulkAddressUploads } from "@/app/db/schema/bulk_address_uploads"; -import { tasks } from "@/app/db/schema/tasks/tasks"; -import { subTasks } from "@/app/db/schema/tasks/subtask"; -import { eq } from "drizzle-orm"; -import { NextRequest, NextResponse } from "next/server"; -import { getServerSession } from "next-auth"; -import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions"; -import { z } from "zod"; -import { createS3Client } from "@/app/utils/s3"; -import { sendToQueue } from "@/app/utils/sqs"; -import S3 from "aws-sdk/clients/s3"; -import * as XLSX from "xlsx"; - -const FIELD_RENAME: Record = { - address_1: "Address 1", - address_2: "Address 2", - address_3: "Address 3", - postcode: "postcode", - internal_reference: "Internal Reference", -}; - -const BodySchema = z.object({ - taskId: z.string().uuid(), - subTaskId: z.string().uuid(), -}); - -function transformFile( - buffer: Buffer, - columnMapping: Record -): { csv: string; error?: never } | { csv?: never; error: string } { - const wb = XLSX.read(buffer, { type: "buffer" }); - const sheet = wb.Sheets[wb.SheetNames[0]]; - const rows = XLSX.utils.sheet_to_json>(sheet, { defval: "" }); - - if (rows.length === 0) return { error: "Empty file" }; - - const sourceHeaders = Object.keys(rows[0]); - const outputHeaders: string[] = []; - const sourceToOutput: Record = {}; - - for (const src of sourceHeaders) { - const mapped = columnMapping[src]; - if (!mapped || mapped === "skip") continue; - const renamed = FIELD_RENAME[mapped] ?? mapped; - outputHeaders.push(renamed); - sourceToOutput[src] = renamed; - } - - if (!outputHeaders.includes("Address 1")) - return { error: 'Mapping must include "Address 1"' }; - if (!outputHeaders.includes("postcode")) - return { error: 'Mapping must include "postcode"' }; - - const outputRows = rows.map((row) => { - const out: Record = {}; - for (const [src, renamed] of Object.entries(sourceToOutput)) { - out[renamed] = row[src] ?? ""; - } - return out; - }); - - const outSheet = XLSX.utils.json_to_sheet(outputRows, { header: outputHeaders }); - return { csv: XLSX.utils.sheet_to_csv(outSheet) }; -} - -export async function POST( - request: NextRequest, - { params }: { params: Promise<{ portfolioId: string; uploadId: string }> } -) { - const session = await getServerSession(AuthOptions); - if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - - const { portfolioId, uploadId } = await params; - - let body; - try { - body = BodySchema.parse(await request.json()); - } catch { - return NextResponse.json({ error: "Invalid input" }, { status: 400 }); - } - - const [upload] = await db - .select() - .from(bulkAddressUploads) - .where(eq(bulkAddressUploads.id, uploadId)) - .limit(1); - - if (!upload) return NextResponse.json({ error: "Not found" }, { status: 404 }); - if (upload.status !== "mapping_complete") - return NextResponse.json({ error: "Upload not ready for onboarding" }, { status: 422 }); - if (!upload.columnMapping) - return NextResponse.json({ error: "Column mapping missing" }, { status: 422 }); - - const s3 = createS3Client(); - const outputS3 = new S3({ - region: process.env.RETROFIT_DATA_DEV_REGION, - accessKeyId: process.env.RETROFIT_DATA_DEV_ACCESS_KEY, - secretAccessKey: process.env.RETROFIT_DATA_DEV_SECRET_KEY, - }); - const outputBucket = process.env.RETROFIT_DATA_DEV_S3_BUCKET_NAME!; - const bucket = upload.s3Bucket; - - let fileBuffer: Buffer; - try { - const obj = await s3 - .getObject({ Bucket: bucket, Key: upload.s3Key }) - .promise(); - fileBuffer = Buffer.from(obj.Body as Uint8Array); - } catch (err) { - console.error("Failed to read source file from S3:", err); - return NextResponse.json({ error: "Failed to read source file" }, { status: 500 }); - } - - const result = transformFile(fileBuffer, upload.columnMapping); - if (result.error) return NextResponse.json({ error: result.error }, { status: 422 }); - - const transformedKey = `bulk_onboarding_inputs/${portfolioId}/${uploadId}.csv`; - try { - await outputS3 - .putObject({ - Bucket: outputBucket, - Key: transformedKey, - Body: result.csv, - ContentType: "text/csv", - }) - .promise(); - } catch (err) { - console.error("Failed to upload transformed CSV:", err); - return NextResponse.json({ error: "Failed to store transformed file" }, { status: 500 }); - } - - const s3Uri = `s3://${outputBucket}/${transformedKey}`; - const queueName = process.env.POSTCODE_SPLITTER_QUEUE_NAME; - if (!queueName) { - console.error("POSTCODE_SPLITTER_QUEUE_NAME not set"); - return NextResponse.json({ error: "Server misconfiguration" }, { status: 500 }); - } - - try { - await sendToQueue( - { task_id: body.taskId, sub_task_id: body.subTaskId, s3_uri: s3Uri }, - { queueName } - ); - } catch (err) { - console.error("Failed to send SQS message:", err); - return NextResponse.json({ error: "Failed to queue onboarding job" }, { status: 500 }); - } - - await Promise.all([ - db.update(bulkAddressUploads) - .set({ status: "processing", taskId: body.taskId }) - .where(eq(bulkAddressUploads.id, uploadId)), - db.update(tasks) - .set({ status: "in progress" }) - .where(eq(tasks.id, body.taskId)), - db.update(subTasks) - .set({ inputs: JSON.stringify({ task_id: body.taskId, sub_task_id: body.subTaskId, s3_uri: s3Uri }) }) - .where(eq(subTasks.id, body.subTaskId)), - ]); - - return NextResponse.json({ taskId: body.taskId }, { status: 200 }); -} diff --git a/src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/route.ts b/src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/route.ts deleted file mode 100644 index 520e933..0000000 --- a/src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/route.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { db } from "@/app/db/db"; -import { bulkAddressUploads } from "@/app/db/schema/bulk_address_uploads"; -import { eq } from "drizzle-orm"; -import { NextRequest, NextResponse } from "next/server"; -import { z } from "zod"; - -const PatchSchema = z.object({ - columnMapping: z.record(z.string(), z.string()), -}); - -export async function PATCH( - request: NextRequest, - { params }: { params: { portfolioId: string; uploadId: string } } -) { - const { uploadId } = params; - - let body; - try { - body = PatchSchema.parse(await request.json()); - } catch { - return NextResponse.json({ msg: "Invalid input" }, { status: 400 }); - } - - const values = Object.values(body.columnMapping); - const hasAddress = values.includes("address_1"); - const hasPostcode = values.includes("postcode"); - if (!hasAddress || !hasPostcode) { - return NextResponse.json( - { msg: "Mapping must include address_1 and postcode." }, - { status: 422 } - ); - } - - try { - const [updated] = await db - .update(bulkAddressUploads) - .set({ columnMapping: body.columnMapping, status: "mapping_complete" }) - .where(eq(bulkAddressUploads.id, uploadId)) - .returning(); - - if (!updated) { - return NextResponse.json({ msg: "Not found" }, { status: 404 }); - } - - return NextResponse.json(updated, { status: 200 }); - } catch (error) { - console.error("Failed to save column mapping:", error); - return NextResponse.json({ msg: "Internal server error" }, { status: 500 }); - } -} diff --git a/src/app/api/portfolio/[portfolioId]/bulk-uploads/route.ts b/src/app/api/portfolio/[portfolioId]/bulk-uploads/route.ts deleted file mode 100644 index 902de6d..0000000 --- a/src/app/api/portfolio/[portfolioId]/bulk-uploads/route.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { db } from "@/app/db/db"; -import { bulkAddressUploads } from "@/app/db/schema/bulk_address_uploads"; -import { eq, desc } from "drizzle-orm"; -import { NextRequest, NextResponse } from "next/server"; - -export async function GET( - _request: NextRequest, - { params }: { params: { portfolioId: string } } -) { - const { portfolioId } = params; - - try { - const uploads = await db - .select() - .from(bulkAddressUploads) - .where(eq(bulkAddressUploads.portfolioId, portfolioId)) - .orderBy(desc(bulkAddressUploads.createdAt)); - - return NextResponse.json(uploads, { status: 200 }); - } catch (error) { - console.error("Failed to fetch bulk uploads:", error); - return NextResponse.json({ msg: "Internal server error" }, { status: 500 }); - } -} diff --git a/src/app/api/portfolio/[portfolioId]/capabilities/route.ts b/src/app/api/portfolio/[portfolioId]/capabilities/route.ts deleted file mode 100644 index 8f7f8b0..0000000 --- a/src/app/api/portfolio/[portfolioId]/capabilities/route.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { db } from "@/app/db/db"; -import { NextRequest, NextResponse } from "next/server"; -import { - portfolioUsers, - portfolioCapabilities, - PortfolioCapabilityType, -} from "@/app/db/schema/portfolio"; -import { user } from "@/app/db/schema/users"; -import { and, eq } from "drizzle-orm"; -import { z } from "zod"; -import { getServerSession } from "next-auth"; -import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions"; - -const CAPABILITY_OPTIONS = ["approver", "contractor"] as const; - -async function getRequestingUserRole(portfolioId: bigint, email: string) { - const rows = await db - .select({ role: portfolioUsers.role }) - .from(portfolioUsers) - .innerJoin(user, eq(user.id, portfolioUsers.userId)) - .where( - and( - eq(portfolioUsers.portfolioId, portfolioId), - eq(user.email, email), - ), - ) - .limit(1); - return rows[0]?.role ?? null; -} - -// GET — list all capability assignments for this portfolio -export async function GET( - _req: NextRequest, - props: { params: Promise<{ portfolioId: string }> }, -) { - const { portfolioId } = await props.params; - - try { - const rows = await db - .select({ - id: portfolioCapabilities.id, - userId: portfolioCapabilities.userId, - capability: portfolioCapabilities.capability, - name: user.firstName, - email: user.email, - }) - .from(portfolioCapabilities) - .leftJoin(user, eq(user.id, portfolioCapabilities.userId)) - .where(eq(portfolioCapabilities.portfolioId, BigInt(portfolioId))); - - return NextResponse.json( - rows.map((r) => ({ - id: r.id?.toString(), - userId: r.userId?.toString(), - capability: r.capability, - name: r.name ?? null, - email: r.email ?? "", - })), - ); - } catch (err) { - console.error("GET /capabilities error:", err); - return NextResponse.json( - { error: "Failed to fetch capabilities" }, - { status: 500 }, - ); - } -} - -// POST — assign a capability to a user -export async function POST( - req: NextRequest, - props: { params: Promise<{ portfolioId: string }> }, -) { - const session = await getServerSession(AuthOptions); - if (!session?.user?.email) { - return NextResponse.json({ error: "Unauthorised" }, { status: 401 }); - } - - const { portfolioId } = await props.params; - const pId = BigInt(portfolioId); - - const requestingRole = await getRequestingUserRole(pId, session.user.email); - if (requestingRole !== "admin" && requestingRole !== "creator") { - return NextResponse.json({ error: "Forbidden" }, { status: 403 }); - } - - const bodySchema = z.object({ - userId: z.string(), - capability: z.enum(CAPABILITY_OPTIONS), - }); - - let body: z.infer; - try { - body = bodySchema.parse(await req.json()); - } catch { - return NextResponse.json({ error: "Invalid body" }, { status: 400 }); - } - - try { - await db - .insert(portfolioCapabilities) - .values({ - portfolioId: pId, - userId: BigInt(body.userId), - capability: body.capability as PortfolioCapabilityType, - }) - .onConflictDoNothing(); - - return NextResponse.json({ success: true }, { status: 200 }); - } catch (err) { - console.error("POST /capabilities error:", err); - return NextResponse.json( - { error: "Failed to assign capability" }, - { status: 500 }, - ); - } -} - -// DELETE — remove a capability from a user -export async function DELETE( - req: NextRequest, - props: { params: Promise<{ portfolioId: string }> }, -) { - const session = await getServerSession(AuthOptions); - if (!session?.user?.email) { - return NextResponse.json({ error: "Unauthorised" }, { status: 401 }); - } - - const { portfolioId } = await props.params; - const pId = BigInt(portfolioId); - - const requestingRole = await getRequestingUserRole(pId, session.user.email); - if (requestingRole !== "admin" && requestingRole !== "creator") { - return NextResponse.json({ error: "Forbidden" }, { status: 403 }); - } - - const bodySchema = z.object({ - userId: z.string(), - capability: z.enum(CAPABILITY_OPTIONS), - }); - - let body: z.infer; - try { - body = bodySchema.parse(await req.json()); - } catch { - return NextResponse.json({ error: "Invalid body" }, { status: 400 }); - } - - try { - await db - .delete(portfolioCapabilities) - .where( - and( - eq(portfolioCapabilities.portfolioId, pId), - eq(portfolioCapabilities.userId, BigInt(body.userId)), - eq( - portfolioCapabilities.capability, - body.capability as PortfolioCapabilityType, - ), - ), - ); - - return NextResponse.json({ success: true }, { status: 200 }); - } catch (err) { - console.error("DELETE /capabilities error:", err); - return NextResponse.json( - { error: "Failed to remove capability" }, - { status: 500 }, - ); - } -} diff --git a/src/app/api/tasks/[taskId]/summary/route.ts b/src/app/api/tasks/[taskId]/summary/route.ts deleted file mode 100644 index 6e5cb5c..0000000 --- a/src/app/api/tasks/[taskId]/summary/route.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { db } from "@/app/db/db"; -import { tasks } from "@/app/db/schema/tasks/tasks"; -import { subTasks } from "@/app/db/schema/tasks/subtask"; -import { eq, count, sql } from "drizzle-orm"; -import { NextRequest, NextResponse } from "next/server"; - -export async function GET( - _request: NextRequest, - { params }: { params: Promise<{ taskId: string }> } -) { - const { taskId } = await params; - - try { - const [row] = await db - .select({ - id: tasks.id, - taskSource: tasks.taskSource, - status: tasks.status, - service: tasks.service, - jobStarted: tasks.jobStarted, - jobCompleted: tasks.jobCompleted, - updatedAt: tasks.updatedAt, - totalSubtasks: count(subTasks.id), - completedSubtasks: sql`count(case when lower(${subTasks.status}) in ('completed', 'complete') then 1 end)::int`, - failedSubtasks: sql`count(case when lower(${subTasks.status}) in ('failed', 'failure', 'error') then 1 end)::int`, - }) - .from(tasks) - .leftJoin(subTasks, eq(subTasks.taskId, tasks.id)) - .where(eq(tasks.id, taskId)) - .groupBy(tasks.id) - .limit(1); - - if (!row) return NextResponse.json({ error: "Not found" }, { status: 404 }); - - return NextResponse.json(row); - } catch (error) { - console.error("Error fetching task summary:", error); - return NextResponse.json({ error: "Failed to fetch task summary" }, { status: 500 }); - } -} diff --git a/src/app/api/tasks/route.ts b/src/app/api/tasks/route.ts index dc6e830..d11a239 100644 --- a/src/app/api/tasks/route.ts +++ b/src/app/api/tasks/route.ts @@ -1,61 +1,7 @@ import { db } from "@/app/db/db"; import { tasks } from "@/app/db/schema/tasks/tasks"; -import { subTasks } from "@/app/db/schema/tasks/subtask"; import { desc, count } from "drizzle-orm"; import { NextRequest, NextResponse } from "next/server"; -import { getServerSession } from "next-auth"; -import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions"; -import { z } from "zod"; - -const CreateTaskSchema = z.object({ - taskSource: z.string().min(1), - service: z.string().optional(), - source: z.literal("portfolio_id").optional(), - sourceId: z.string().optional(), - inputs: z.record(z.unknown()).optional(), -}); - -export async function POST(request: NextRequest) { - const session = await getServerSession(AuthOptions); - if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - - let body; - try { - body = CreateTaskSchema.parse(await request.json()); - } catch { - return NextResponse.json({ error: "Invalid input" }, { status: 400 }); - } - - try { - const now = new Date(); - - const [task] = await db - .insert(tasks) - .values({ - taskSource: body.taskSource, - service: body.service, - source: body.source, - sourceId: body.sourceId, - status: "waiting", - jobStarted: now, - }) - .returning(); - - const [subTask] = await db - .insert(subTasks) - .values({ - taskId: task.id, - status: "waiting", - inputs: body.inputs ? JSON.stringify(body.inputs) : null, - }) - .returning(); - - return NextResponse.json({ taskId: task.id, subTaskId: subTask.id }, { status: 201 }); - } catch (error) { - console.error("Failed to create task:", error); - return NextResponse.json({ error: "Internal server error" }, { status: 500 }); - } -} export async function GET(request: NextRequest) { try { diff --git a/src/app/api/upload/bulk-addresses/confirm/route.ts b/src/app/api/upload/bulk-addresses/confirm/route.ts deleted file mode 100644 index edc6357..0000000 --- a/src/app/api/upload/bulk-addresses/confirm/route.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { db } from "@/app/db/db"; -import { bulkAddressUploads } from "@/app/db/schema/bulk_address_uploads"; -import { NextRequest, NextResponse } from "next/server"; -import { z } from "zod"; - -const BodySchema = z.object({ - fileKey: z.string(), - filename: z.string(), - portfolioId: z.string(), - userId: z.string(), - sourceHeaders: z.array(z.string()).default([]), -}); - -export async function POST(request: NextRequest) { - let body; - try { - body = BodySchema.parse(await request.json()); - } catch (error) { - console.error("Invalid input:", error); - return NextResponse.json({ msg: "Invalid input" }, { status: 400 }); - } - - const bucket = process.env.RETROFIT_PLAN_INPUT_BUCKET_NAME; - if (!bucket) { - console.error("RETROFIT_PLAN_INPUT_BUCKET_NAME not set"); - return NextResponse.json({ msg: "Server misconfiguration" }, { status: 500 }); - } - - try { - const [record] = await db - .insert(bulkAddressUploads) - .values({ - portfolioId: body.portfolioId, - userId: body.userId, - s3Bucket: bucket, - s3Key: body.fileKey, - filename: body.filename, - sourceHeaders: body.sourceHeaders, - }) - .returning(); - - return NextResponse.json( - { id: record.id, s3Key: record.s3Key, s3Bucket: record.s3Bucket, status: record.status }, - { status: 201 } - ); - } catch (error) { - console.error("Failed to record upload:", error); - return NextResponse.json({ msg: "Internal server error" }, { status: 500 }); - } -} diff --git a/src/app/api/upload/bulk-addresses/route.ts b/src/app/api/upload/bulk-addresses/route.ts deleted file mode 100644 index 9eb1c41..0000000 --- a/src/app/api/upload/bulk-addresses/route.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { createS3Client } from "@/app/utils/s3"; -import { NextRequest, NextResponse } from "next/server"; -import { z } from "zod"; - -const BodySchema = z.object({ - userId: z.string(), - portfolioId: z.string(), - fileKey: z.string(), - contentType: z.string(), -}); - -export async function POST(request: NextRequest) { - let body; - try { - body = BodySchema.parse(await request.json()); - } catch (error) { - console.error("Invalid input:", error); - return NextResponse.json({ msg: "Invalid input" }, { status: 400 }); - } - - try { - const s3 = createS3Client(); - - const preSignedUrl = await s3.getSignedUrlPromise("putObject", { - Bucket: process.env.RETROFIT_PLAN_INPUT_BUCKET_NAME, - Key: body.fileKey, - ContentType: body.contentType, - Expires: 5 * 60, - }); - - return NextResponse.json({ url: preSignedUrl }, { status: 200 }); - } catch (error) { - console.error(error); - return NextResponse.json({ msg: "Internal server error" }, { status: 500 }); - } -} diff --git a/src/app/api/upload/contractor-install/route.ts b/src/app/api/upload/contractor-install/route.ts deleted file mode 100644 index c3c3fa0..0000000 --- a/src/app/api/upload/contractor-install/route.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { db } from "@/app/db/db"; -import { NextRequest, NextResponse } from "next/server"; -import { uploadedFiles } from "@/app/db/schema/uploaded_files"; -import { user } from "@/app/db/schema/users"; -import { eq, inArray } from "drizzle-orm"; -import { z } from "zod"; -import { getServerSession } from "next-auth"; -import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions"; - -// POST — record a contractor install document in uploaded_files (fileType optional — can be classified later) -export async function POST(req: NextRequest) { - const session = await getServerSession(AuthOptions); - if (!session?.user?.email) { - return NextResponse.json({ error: "Unauthorised" }, { status: 401 }); - } - - const bodySchema = z.object({ - s3FileKey: z.string(), - s3FileBucket: z.string(), - fileType: z.string().optional(), // optional — null means unclassified - measureName: z.string().optional(), - uprn: z.string().optional(), - hubspotDealId: z.string().optional(), - landlordPropertyId: z.string().optional(), - }); - - let body: z.infer; - try { - body = bodySchema.parse(await req.json()); - } catch { - return NextResponse.json({ error: "Invalid body" }, { status: 400 }); - } - - try { - const userRow = await db - .select({ id: user.id }) - .from(user) - .where(eq(user.email, session.user.email)) - .limit(1); - - const uploadedBy = userRow[0]?.id ?? null; - - const [inserted] = await db - .insert(uploadedFiles) - .values({ - s3FileBucket: body.s3FileBucket, - s3FileKey: body.s3FileKey, - s3UploadTimestamp: new Date(), - fileType: (body.fileType as any) ?? null, - source: "contractor", - measureName: body.measureName ?? null, - uploadedBy: uploadedBy ?? undefined, - uprn: body.uprn ? BigInt(body.uprn) : undefined, - hubsotDealId: body.hubspotDealId ?? null, - landlordPropertyId: body.landlordPropertyId ?? null, - }) - .returning({ id: uploadedFiles.id }); - - return NextResponse.json({ id: inserted.id.toString() }, { status: 201 }); - } catch (err) { - console.error("POST /upload/contractor-install error:", err); - return NextResponse.json({ error: "Failed to record upload" }, { status: 500 }); - } -} - -// PATCH — update fileType and measureName for previously unclassified uploads -export async function PATCH(req: NextRequest) { - const session = await getServerSession(AuthOptions); - if (!session?.user?.email) { - return NextResponse.json({ error: "Unauthorised" }, { status: 401 }); - } - - const bodySchema = z.object({ - updates: z.array( - z.object({ - id: z.string(), - fileType: z.string(), - measureName: z.string().optional(), - }), - ), - }); - - let body: z.infer; - try { - body = bodySchema.parse(await req.json()); - } catch { - return NextResponse.json({ error: "Invalid body" }, { status: 400 }); - } - - if (body.updates.length === 0) { - return NextResponse.json({ success: true }); - } - - try { - // Update each record individually (small batches — no bulk update without raw SQL) - for (const update of body.updates) { - await db - .update(uploadedFiles) - .set({ - fileType: update.fileType as any, - measureName: update.measureName ?? null, - }) - .where(eq(uploadedFiles.id, BigInt(update.id))); - } - - return NextResponse.json({ success: true }); - } catch (err) { - console.error("PATCH /upload/contractor-install error:", err); - return NextResponse.json({ error: "Failed to update classifications" }, { status: 500 }); - } -} diff --git a/src/app/components/portfolio/AddNew.tsx b/src/app/components/portfolio/AddNew.tsx index 2dfe23b..8859507 100644 --- a/src/app/components/portfolio/AddNew.tsx +++ b/src/app/components/portfolio/AddNew.tsx @@ -37,112 +37,112 @@ export default function AddNew({ return ( <> - setIsBulkUploadOpen(false)} - portfolioId={portfolioId} - /> - - + - - New Property - - + > + + New Property + + - -
- {/* Remote Assessment */} - - {({ active }) => ( - - )} - +
+ + Remote Assessment + {loadingRemote && ( + + )} + + + Run a remote assessment for a single property. + +
+ + )} + - {/* CSV Upload */} - - {({ active }) => ( - - )} - +
+ + File Import + + + For bulk uploads, please contact a Domna user. + +
+ + )} + - {/* Bulk Upload (Coming Soon) */} - - {({ active }) => ( - - )} - -
-
-
+ + + Upload multiple addresses in one go. + + + + )} + + + + ); } diff --git a/src/app/components/portfolio/BulkUploadComingSoonModal.tsx b/src/app/components/portfolio/BulkUploadComingSoonModal.tsx deleted file mode 100644 index 1cd47bf..0000000 --- a/src/app/components/portfolio/BulkUploadComingSoonModal.tsx +++ /dev/null @@ -1,459 +0,0 @@ -"use client"; - -import { - Dialog, - DialogBackdrop, - DialogPanel, - DialogTitle, - Transition, - TransitionChild, -} from "@headlessui/react"; -import { Fragment, useRef, useState, DragEvent } from "react"; -import * as XLSX from "xlsx"; -import { - XMarkIcon, - DocumentTextIcon, - ArrowDownTrayIcon, - CloudArrowUpIcon, - InformationCircleIcon, - ArrowRightIcon, - CheckCircleIcon, - ExclamationCircleIcon, -} from "@heroicons/react/24/outline"; -import { useSession } from "next-auth/react"; -import { useRouter } from "next/navigation"; - -const MAX_FILE_SIZE_MB = 50; -const ALLOWED_EXTENSIONS = [".csv", ".xlsx", ".xls"]; -const CONTENT_TYPES: Record = { - ".csv": "text/csv", - ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - ".xls": "application/vnd.ms-excel", -}; - -interface BulkUploadComingSoonModalProps { - isOpen: boolean; - onClose: () => void; - portfolioId: string; -} - -function downloadTemplate() { - const ws = XLSX.utils.aoa_to_sheet([["Internal Reference (Optional)", "Address", "Postcode"]]); - const wb = XLSX.utils.book_new(); - XLSX.utils.book_append_sheet(wb, ws, "Properties"); - XLSX.writeFile(wb, "bulk_upload_template.xlsx"); -} - -function getFileExtension(filename: string): string { - return filename.slice(filename.lastIndexOf(".")).toLowerCase(); -} - -function generateS3Key(userId: string, portfolioId: string, ext: string): string { - const timestamp = new Date().toISOString().replace(/[:.-]/g, ""); - return `bulk-addresses/${userId}/${portfolioId}/${timestamp}/addresses${ext}`; -} - -function validateFile(file: File): string | null { - const sizeMB = file.size / (1024 * 1024); - if (sizeMB > MAX_FILE_SIZE_MB) { - return `File too large. Max ${MAX_FILE_SIZE_MB}MB.`; - } - const ext = getFileExtension(file.name); - if (!ALLOWED_EXTENSIONS.includes(ext)) { - return "Only CSV or Excel files allowed."; - } - return null; -} - -async function validateHeaders(file: File): Promise<{ error: string | null; headers: string[] }> { - const ext = getFileExtension(file.name); - let headers: string[] = []; - - if (ext === ".csv") { - const text = await file.text(); - const firstLine = text.split(/\r?\n/)[0] ?? ""; - headers = firstLine.split(",").map((h) => h.trim().replace(/^["']|["']$/g, "")); - } else { - const buffer = await file.arrayBuffer(); - const wb = XLSX.read(buffer, { sheetRows: 1 }); - const sheet = wb.Sheets[wb.SheetNames[0]]; - const rows = XLSX.utils.sheet_to_json(sheet, { header: 1 }); - headers = ((rows[0] as string[]) ?? []).map((h) => String(h ?? "").trim()); - } - - const normalised = headers.map((h) => h.toLowerCase()); - const hasAddress = normalised.some((h) => h.startsWith("address")); - const hasPostcode = normalised.some((h) => h === "postcode"); - - if (!hasAddress && !hasPostcode) { - return { error: "Missing required columns: Address and Postcode.", headers }; - } - if (!hasAddress) { - return { error: "Missing required column: Address (or Address 1, Address 2, etc.).", headers }; - } - if (!hasPostcode) { - return { error: 'Missing required column: "Postcode".', headers }; - } - return { error: null, headers }; -} - -async function getPresignedUrl( - userId: string, - portfolioId: string, - fileKey: string, - contentType: string -): Promise { - const res = await fetch("/api/upload/bulk-addresses", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ userId, portfolioId, fileKey, contentType }), - }); - if (!res.ok) throw new Error("Failed to generate upload URL."); - const data = await res.json(); - return data.url; -} - -export default function BulkUploadComingSoonModal({ - isOpen, - onClose, - portfolioId, -}: BulkUploadComingSoonModalProps) { - const session = useSession(); - const router = useRouter(); - const fileInputRef = useRef(null); - - const [isDragging, setIsDragging] = useState(false); - const [selectedFile, setSelectedFile] = useState(null); - const [sourceHeaders, setSourceHeaders] = useState([]); - const [validationError, setValidationError] = useState(null); - const [validating, setValidating] = useState(false); - const [uploading, setUploading] = useState(false); - const [uploadProgress, setUploadProgress] = useState(null); - const [uploadError, setUploadError] = useState(null); - - async function handleFile(file: File) { - setUploadError(null); - setSelectedFile(null); - setValidationError(null); - - const sizeOrTypeError = validateFile(file); - if (sizeOrTypeError) { - setValidationError(sizeOrTypeError); - return; - } - - setValidating(true); - const { error: headerError, headers } = await validateHeaders(file); - setValidating(false); - - if (headerError) { - setValidationError(headerError); - return; - } - - setSourceHeaders(headers); - setSelectedFile(file); - } - - function handleDragOver(e: DragEvent) { - e.preventDefault(); - setIsDragging(true); - } - - function handleDragLeave() { - setIsDragging(false); - } - - function handleDrop(e: DragEvent) { - e.preventDefault(); - setIsDragging(false); - const file = e.dataTransfer.files[0]; - if (file) handleFile(file); - } - - function handleInputChange(e: React.ChangeEvent) { - const file = e.target.files?.[0]; - if (file) handleFile(file); - } - - function handleClose() { - setSelectedFile(null); - setSourceHeaders([]); - setValidationError(null); - setValidating(false); - setUploadError(null); - setUploading(false); - setUploadProgress(null); - onClose(); - } - - async function handleUpload() { - const userId = String(session.data?.user?.dbId ?? ""); - if (!selectedFile || !userId) return; - - setUploading(true); - setUploadProgress(0); - setUploadError(null); - - try { - const ext = getFileExtension(selectedFile.name); - const contentType = CONTENT_TYPES[ext] ?? "application/octet-stream"; - const fileKey = generateS3Key(userId, portfolioId, ext); - - const presignedUrl = await getPresignedUrl(userId, portfolioId, fileKey, contentType); - - await new Promise((resolve, reject) => { - const xhr = new XMLHttpRequest(); - xhr.open("PUT", presignedUrl); - xhr.setRequestHeader("Content-Type", contentType); - xhr.upload.addEventListener("progress", (e) => { - if (e.lengthComputable) { - setUploadProgress(Math.round((e.loaded / e.total) * 100)); - } - }); - xhr.onload = () => { - if (xhr.status >= 200 && xhr.status < 300) resolve(); - else reject(new Error(`S3 upload failed: ${xhr.status}`)); - }; - xhr.onerror = () => reject(new Error("Network error during upload")); - xhr.send(selectedFile); - }); - - const confirmRes = await fetch("/api/upload/bulk-addresses/confirm", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ fileKey, filename: selectedFile.name, portfolioId, userId, sourceHeaders }), - }); - if (!confirmRes.ok) throw new Error("Failed to record upload."); - - const { id: uploadId } = await confirmRes.json(); - router.push(`/portfolio/${portfolioId}/bulk-upload/${uploadId}/map-columns`); - onClose(); - } catch (err) { - setUploadError("Upload failed. Please try again, or contact a Domna representative if the issue persists."); - } finally { - setUploading(false); - setUploadProgress(null); - } - } - - const canUpload = !!selectedFile && !uploading && !validating; - - return ( - - - {/* Backdrop */} - - - - - {/* Panel */} -
- - - - {/* Header */} -
-
- - Bulk Upload: New Properties - -

- This workflow is designed for adding new residential or commercial - assets to your portfolio. Upload your dataset to begin the - transformation. -

-
- -
- - {/* Content */} -
- - {/* Template section */} -
-
-
- -
-
-

Required Template Format

-

- Must contain:{" "} - - Address, Postcode - -

-
-
- -
- - {/* Dropzone */} -
!uploading && fileInputRef.current?.click()} - onDragOver={handleDragOver} - onDragLeave={handleDragLeave} - onDrop={handleDrop} - className={`border-2 border-dashed rounded-2xl p-12 flex flex-col items-center justify-center transition-colors ${ - uploading || validating - ? "border-gray-200 bg-gray-50 cursor-default" - : validationError - ? "border-red-300 bg-red-50 cursor-pointer" - : isDragging - ? "border-midblue bg-blue-50 cursor-copy" - : selectedFile - ? "border-green-400 bg-green-50 cursor-pointer" - : "border-gray-200 hover:border-gray-300 hover:bg-gray-50 cursor-pointer" - }`} - > - e.stopPropagation()} - /> - - {validating ? ( - <> -
- -
-

Checking headers…

-

Validating column structure

- - ) : uploading ? ( - <> -
- -
-

Uploading…

-

{selectedFile?.name}

-
-
-
-

{uploadProgress ?? 0}%

- - ) : validationError ? ( - <> - -

{validationError}

-

Click to choose a different file

- - ) : selectedFile ? ( - <> - -

{selectedFile.name}

-

Click to change file

- - ) : ( - <> -
- -
-

- Drag and drop CSV or XLSX -

-

- or click to browse · Max {MAX_FILE_SIZE_MB}MB -

- - )} -
- - {/* Upload error */} - {uploadError && ( -

- - {uploadError} -

- )} - - {/* Info strip */} -
- - - Properties will be automatically validated against national - architectural databases. - -
-
- - {/* Footer */} -
-
- - -
-
- -
-
- - - -
-
-
- ); -} diff --git a/src/app/db/schema/approvals.ts b/src/app/db/schema/approvals.ts deleted file mode 100644 index 4d615d5..0000000 --- a/src/app/db/schema/approvals.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { - bigserial, - boolean, - text, - timestamp, - pgTable, - bigint, - index, - unique, -} from "drizzle-orm/pg-core"; -import { user } from "./users"; -import { InferModel } from "drizzle-orm"; - -// Current approval state per (deal, measure) — upserted on each change. -// Query WHERE is_approved = true to get the currently approved set. -export const dealMeasureApprovals = pgTable( - "deal_measure_approvals", - { - id: bigserial("id", { mode: "bigint" }).primaryKey(), - hubspotDealId: text("hubspot_deal_id").notNull(), - measureName: text("measure_name").notNull(), - isApproved: boolean("is_approved").notNull().default(true), - approvedBy: bigint("approved_by", { mode: "bigint" }) - .notNull() - .references(() => user.id), - approvedAt: timestamp("approved_at", { withTimezone: true }) - .defaultNow() - .notNull(), - }, - (table) => [ - unique("uq_deal_measure").on(table.hubspotDealId, table.measureName), - index("idx_deal_measure_approvals_deal_id").on(table.hubspotDealId), - ], -); - -// Append-only audit log — never deleted. -export const dealMeasureApprovalEvents = pgTable( - "deal_measure_approval_events", - { - id: bigserial("id", { mode: "bigint" }).primaryKey(), - hubspotDealId: text("hubspot_deal_id").notNull(), - measureName: text("measure_name").notNull(), - // 'approved' | 'unapproved' - action: text("action").notNull(), - actedBy: bigint("acted_by", { mode: "bigint" }) - .notNull() - .references(() => user.id), - actedAt: timestamp("acted_at", { withTimezone: true }) - .defaultNow() - .notNull(), - }, - (table) => [ - index("idx_deal_measure_events_deal_id").on(table.hubspotDealId), - index("idx_deal_measure_events_acted_at").on(table.actedAt), - ], -); - -export type DealMeasureApproval = InferModel< - typeof dealMeasureApprovals, - "select" ->; -export type DealMeasureApprovalEvent = InferModel< - typeof dealMeasureApprovalEvents, - "select" ->; diff --git a/src/app/db/schema/bulk_address_uploads.ts b/src/app/db/schema/bulk_address_uploads.ts index 52f3211..082ca8b 100644 --- a/src/app/db/schema/bulk_address_uploads.ts +++ b/src/app/db/schema/bulk_address_uploads.ts @@ -11,8 +11,6 @@ export const bulkAddressUploads = pgTable("bulk_address_uploads", { status: text("status").notNull().default("ready_for_processing"), sourceHeaders: text("source_headers").array().notNull().default(sql`'{}'`), columnMapping: jsonb("column_mapping").$type>(), - taskId: uuid("task_id"), - combinedOutputS3Uri: text("combined_output_s3_uri"), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp("updated_at", { withTimezone: true }) .notNull() diff --git a/src/app/db/schema/portfolio.ts b/src/app/db/schema/portfolio.ts index 8e231bc..d424634 100644 --- a/src/app/db/schema/portfolio.ts +++ b/src/app/db/schema/portfolio.ts @@ -7,7 +7,6 @@ import { pgEnum, integer, bigint, - unique, } from "drizzle-orm/pg-core"; import { user } from "./users"; import { InferModel } from "drizzle-orm"; @@ -125,43 +124,7 @@ export const portfolioUsers = pgTable("portfolioUsers", { .notNull(), }); -export const PortfolioCapability: [string, ...string[]] = [ - "approver", - "contractor", -]; -export type PortfolioCapabilityType = "approver" | "contractor"; - -export const portfolioCapabilityEnum = pgEnum( - "portfolio_capability", - PortfolioCapability as [string, ...string[]], -); - -export const portfolioCapabilities = pgTable( - "portfolio_capabilities", - { - id: bigserial("id", { mode: "bigint" }).primaryKey(), - userId: bigint("user_id", { mode: "bigint" }) - .notNull() - .references(() => user.id), - portfolioId: bigint("portfolio_id", { mode: "bigint" }) - .notNull() - .references(() => portfolio.id), - capability: portfolioCapabilityEnum("capability").notNull(), - createdAt: timestamp("created_at", { precision: 6, withTimezone: true }) - .defaultNow() - .notNull(), - updatedAt: timestamp("updated_at", { precision: 6, withTimezone: true }) - .defaultNow() - .notNull(), - }, - (table) => [unique().on(table.userId, table.portfolioId, table.capability)], -); - export type Portfolio = InferModel; export type NewPortfolio = InferModel; export type PortfolioUsers = InferModel; export type NewPortfolioUsers = InferModel; -export type PortfolioCapabilities = InferModel< - typeof portfolioCapabilities, - "select" ->; diff --git a/src/app/db/schema/recommendations.ts b/src/app/db/schema/recommendations.ts index 2cef2bd..3ffdff2 100644 --- a/src/app/db/schema/recommendations.ts +++ b/src/app/db/schema/recommendations.ts @@ -58,13 +58,6 @@ export const measureTypeEnum = pgEnum("measure_type", [ // Other fabric / hot water "hot_water_tank_insulation", "sealing_open_fireplace", - - // Contractor workflow measures - "damp_mould", - "door_undercut", - "extractor_fan", - "loft_board", - "trickle_vent", ]); export const recommendation = pgTable( diff --git a/src/app/db/schema/uploaded_files.ts b/src/app/db/schema/uploaded_files.ts index abf2fa1..18d99a0 100644 --- a/src/app/db/schema/uploaded_files.ts +++ b/src/app/db/schema/uploaded_files.ts @@ -1,8 +1,6 @@ import { bigint, bigserial, pgEnum, pgTable, text, timestamp } from "drizzle-orm/pg-core"; -import { user } from "./users"; export const fileType = pgEnum("file_type", [ - // Survey documents (existing) "photo_pack", "site_note", "rd_sap_site_note", @@ -14,33 +12,14 @@ export const fileType = pgEnum("file_type", [ "pas_2023_occupancy", "ecmk_site_note", "ecmk_rd_sap_site_note", - "ecmk_survey_xml", - // Contractor install documentation - "pre_photo", - "mid_photo", - "post_photo", - "pre_installation_building_inspection", - "claim_of_compliance", - "handover_pack", - "insurance_guarantee", - "installer_qualifications", - "mcs_compliance_certificate", - "minor_works_electrical_certificate", - "point_of_work_risk_assessment", - "installer_feedback", - "workmanship_warranty", - "g98_notification", - "certificate_of_conformity", - "ventilation_assessment_checklist", - "contractor_other", + "ecmk_survey_xml" ]); export const fileSource = pgEnum("file_source", [ "pas hub", "sharepoint", "hubspot", - "ecmk", - "contractor", + "ecmk" ]); export const uploadedFiles = pgTable( @@ -57,8 +36,6 @@ export const uploadedFiles = pgTable( hubsotDealId: text("hubspot_deal_id"), hubspotListingId: bigint("hubspot_listing_id", { mode: "bigint" }), fileType: fileType("file_type"), - source: fileSource("file_source"), - measureName: text("measure_name"), - uploadedBy: bigint("uploaded_by", { mode: "bigint" }).references(() => user.id), + source: fileSource("file_source") } ); \ No newline at end of file diff --git a/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/OnboardingProgress.tsx b/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/OnboardingProgress.tsx deleted file mode 100644 index 552d4a4..0000000 --- a/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/OnboardingProgress.tsx +++ /dev/null @@ -1,112 +0,0 @@ -"use client"; - -import { useEffect, useState, useRef } from "react"; -import Link from "next/link"; - -interface TaskData { - id: string; - taskSource: string; - status: string; - totalSubtasks: number; - completedSubtasks: number; - failedSubtasks: number; -} - -interface Props { - taskId: string; - portfolioSlug: string; - isDomnaUser: boolean; -} - -const TERMINAL_STATUSES = new Set(["complete", "completed", "failed", "failure", "error"]); - -export default function OnboardingProgress({ taskId, portfolioSlug, isDomnaUser }: Props) { - const [data, setData] = useState(null); - const [fetchError, setFetchError] = useState(false); - const intervalRef = useRef | null>(null); - - useEffect(() => { - async function poll() { - try { - const res = await fetch(`/api/tasks/${taskId}/summary`); - if (!res.ok) { setFetchError(true); return; } - const json: TaskData = await res.json(); - setData(json); - if (TERMINAL_STATUSES.has(json.status.toLowerCase())) { - if (intervalRef.current) clearInterval(intervalRef.current); - } - } catch { - setFetchError(true); - } - } - - poll(); - intervalRef.current = setInterval(poll, 3000); - return () => { if (intervalRef.current) clearInterval(intervalRef.current); }; - }, [taskId]); - - if (fetchError) return null; - if (!data) { - return ( -
- - Loading progress… -
- ); - } - - const total = data.totalSubtasks; - const complete = data.completedSubtasks; - const failed = data.failedSubtasks; - const percent = total > 0 ? Math.round((complete / total) * 100) : 0; - const isDone = TERMINAL_STATUSES.has(data.status.toLowerCase()); - const isFailed = ["failed", "failure", "error"].includes(data.status.toLowerCase()); - - return ( -
- {/* Progress bar */} -
-
0 ? `${percent}%` : "4%" }} - /> -
- - {/* Counts */} -
- {total > 0 && ( - - {complete} / {total} batches complete - - )} - {failed > 0 && ( - - - {failed} failed - - )} - {!isDone && ( - - - Running - - )} - {isDone && !isFailed && ( - - - Complete - - )} -
- - {isDomnaUser && ( - - View detailed logs - - )} -
- ); -} diff --git a/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/StartOnboardingButton.tsx b/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/StartOnboardingButton.tsx deleted file mode 100644 index 60dad11..0000000 --- a/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/StartOnboardingButton.tsx +++ /dev/null @@ -1,87 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { useRouter } from "next/navigation"; -import { ArrowRightIcon } from "@heroicons/react/24/outline"; - -interface Props { - portfolioId: string; - uploadId: string; - filename: string; -} - -export default function StartOnboardingButton({ portfolioId, uploadId, filename }: Props) { - const router = useRouter(); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - async function handleStart() { - setLoading(true); - setError(null); - - try { - const taskRes = await fetch("/api/tasks", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - taskSource: `Address Onboarding – ${filename}`, - service: "address2uprn", - source: "portfolio_id", - sourceId: portfolioId, - inputs: { bulk_upload_id: uploadId }, - }), - }); - - if (!taskRes.ok) { - const data = await taskRes.json().catch(() => ({})); - throw new Error(data.error ?? "Failed to create task"); - } - - const { taskId, subTaskId } = await taskRes.json(); - - const onboardRes = await fetch( - `/api/portfolio/${portfolioId}/bulk-uploads/${uploadId}/onboard`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ taskId, subTaskId }), - } - ); - - if (!onboardRes.ok) { - const data = await onboardRes.json().catch(() => ({})); - throw new Error(data.error ?? "Failed to start onboarding"); - } - - router.refresh(); - } catch (err) { - setError(err instanceof Error ? err.message : "Something went wrong"); - setLoading(false); - } - } - - return ( -
- - {error &&

{error}

} -
- ); -} diff --git a/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/map-columns/MapColumnsClient.tsx b/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/map-columns/MapColumnsClient.tsx deleted file mode 100644 index 1915282..0000000 --- a/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/map-columns/MapColumnsClient.tsx +++ /dev/null @@ -1,271 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { useRouter } from "next/navigation"; -import Link from "next/link"; -import { - ArrowLeftIcon, - ArrowRightIcon, - TableCellsIcon, - ArrowsRightLeftIcon, -} from "@heroicons/react/24/outline"; - -const INTERNAL_FIELDS = [ - { value: "address_1", label: "Address 1", required: true }, - { value: "address_2", label: "Address 2", required: false }, - { value: "address_3", label: "Address 3", required: false }, - { value: "postcode", label: "Postcode", required: true }, - { value: "internal_reference", label: "Internal Reference (Optional)", required: false }, - { value: "skip", label: "Skip this column", required: false }, -]; - -const REQUIRED_VALUES = ["address_1", "postcode"]; - -function autoDetect(header: string): string { - const h = header.toLowerCase().replace(/[\s_\-]/g, ""); - if (/^(address|addr)(line)?(1|one)?$/.test(h)) return "address_1"; - if (/^(address|addr)(line)?(2|two)|^street$/.test(h)) return "address_2"; - if (/^(address|addr)(line)?(3|three)|^locality$|^town$|^city$/.test(h)) return "address_3"; - if (/^post(al)?code$|^postcode$|^pcode$/.test(h)) return "postcode"; - if (/^(internal)?ref(erence)?$|^id$/.test(h)) return "internal_reference"; - return "skip"; -} - -function buildInitialMapping( - headers: string[], - existing?: Record -): Record { - const mapping: Record = {}; - for (const h of headers) { - mapping[h] = existing?.[h] ?? autoDetect(h); - } - return mapping; -} - -interface Props { - portfolioId: string; - uploadId: string; - filename: string; - sourceHeaders: string[]; - existingMapping?: Record; -} - -export default function MapColumnsClient({ - portfolioId, - uploadId, - filename, - sourceHeaders, - existingMapping, -}: Props) { - const router = useRouter(); - const [mapping, setMapping] = useState>( - buildInitialMapping(sourceHeaders, existingMapping) - ); - const [submitting, setSubmitting] = useState(false); - const [error, setError] = useState(null); - - const mappedValues = Object.values(mapping).filter((v) => v !== "skip"); - const missingRequired = REQUIRED_VALUES.filter((r) => !mappedValues.includes(r)); - const canSubmit = missingRequired.length === 0 && !submitting; - - function setField(header: string, value: string) { - setMapping((prev) => ({ ...prev, [header]: value })); - } - - async function handleSubmit() { - if (!canSubmit) return; - setSubmitting(true); - setError(null); - - try { - const res = await fetch( - `/api/portfolio/${portfolioId}/bulk-uploads/${uploadId}`, - { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ columnMapping: mapping }), - } - ); - - if (!res.ok) { - const data = await res.json().catch(() => ({})); - throw new Error(data.msg ?? "Failed to save mapping."); - } - - router.push(`/portfolio/${portfolioId}/bulk-upload/${uploadId}`); - } catch (err) { - setError(err instanceof Error ? err.message : "Something went wrong."); - setSubmitting(false); - } - } - - return ( -
- {/* Breadcrumb + step */} -
-

- Bulk Uploads › Column Remapper -

-
- - Step 2 of 3 - -
- {[1, 2, 3].map((s) => ( -
- ))} -
-
-
- - {/* Header */} -
-

- Column Remapper -

-

- Align your spreadsheet headers with our internal property data structure to - ensure accurate address processing. -

-
- - {/* Table */} -
- {/* Column headers */} -
- - Spreadsheet Header - - - - Internal Field Mapping - - - Status - -
- - {sourceHeaders.length === 0 ? ( -
- No headers found in this file. -
- ) : ( -
- {sourceHeaders.map((header) => { - const value = mapping[header] ?? "skip"; - const isMapped = value !== "skip"; - return ( -
- {/* Source header */} -
-
- -
-
-

{header}

-

Source column

-
-
- - {/* Arrow */} -
- -
- - {/* Dropdown */} -
- -
- - {/* Status badge */} -
- - - {isMapped ? "Mapped" : "Skipped"} - -
-
- ); - })} -
- )} -
- - {/* Validation error */} - {missingRequired.length > 0 && ( -

- Required fields not yet mapped:{" "} - {missingRequired - .map((r) => INTERNAL_FIELDS.find((f) => f.value === r)?.label) - .join(", ")} -

- )} - {error &&

{error}

} - - {/* Footer */} -
- - - Back - - -
- - Cancel - - -
-
- - {/* Pro tip */} -
-

- Pro Tip -

-

- “Ensure your source file doesn't have blank headers. Any column mapped to - “Skip” will be ignored during import.” -

-
-
- ); -} diff --git a/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/map-columns/page.tsx b/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/map-columns/page.tsx deleted file mode 100644 index 3ce0ac6..0000000 --- a/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/map-columns/page.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { db } from "@/app/db/db"; -import { bulkAddressUploads } from "@/app/db/schema/bulk_address_uploads"; -import { eq } from "drizzle-orm"; -import { getServerSession } from "next-auth"; -import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions"; -import { redirect, notFound } from "next/navigation"; -import MapColumnsClient from "./MapColumnsClient"; - -export default async function MapColumnsPage(props: { - params: Promise<{ slug: string; uploadId: string }>; -}) { - const { slug, uploadId } = await props.params; - const session = await getServerSession(AuthOptions); - if (!session) redirect("/login"); - - const [upload] = await db - .select() - .from(bulkAddressUploads) - .where(eq(bulkAddressUploads.id, uploadId)) - .limit(1); - - if (!upload) notFound(); - - return ( - - ); -} diff --git a/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/page.tsx b/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/page.tsx deleted file mode 100644 index a85440f..0000000 --- a/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/page.tsx +++ /dev/null @@ -1,163 +0,0 @@ -"use server"; - -import { db } from "@/app/db/db"; -import { bulkAddressUploads } from "@/app/db/schema/bulk_address_uploads"; -import { eq } from "drizzle-orm"; -import { getServerSession } from "next-auth"; -import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions"; -import { redirect, notFound } from "next/navigation"; -import Link from "next/link"; -import { - ArrowLeftIcon, - ArrowRightIcon, - CheckCircleIcon, - ClockIcon, - ExclamationCircleIcon, - ArrowPathIcon, -} from "@heroicons/react/24/outline"; -import StartOnboardingButton from "./StartOnboardingButton"; -import OnboardingProgress from "./OnboardingProgress"; - -function formatDate(date: Date) { - return new Intl.DateTimeFormat("en-GB", { - day: "2-digit", - month: "short", - year: "numeric", - hour: "2-digit", - minute: "2-digit", - }).format(date); -} - -const STATUS_CONFIG = { - ready_for_processing: { - icon: ClockIcon, - iconBg: "bg-amber-50", - iconColor: "text-amber-500", - title: "Awaiting column mapping", - body: "Map your spreadsheet columns to our internal fields before processing can begin.", - cta: true, - }, - mapping_complete: { - icon: CheckCircleIcon, - iconBg: "bg-blue-50", - iconColor: "text-blue-500", - title: "Mapping complete", - body: "Column mapping saved. Start onboarding to begin matching your addresses to UPRNs.", - cta: true, - }, - processing: { - icon: ArrowPathIcon, - iconBg: "bg-blue-50", - iconColor: "text-blue-500", - title: "Processing…", - body: "Your file is currently being processed. This may take a few minutes.", - cta: false, - }, - complete: { - icon: CheckCircleIcon, - iconBg: "bg-green-50", - iconColor: "text-green-500", - title: "Processing complete", - body: "All addresses have been imported into your portfolio.", - cta: false, - }, - failed: { - icon: ExclamationCircleIcon, - iconBg: "bg-red-50", - iconColor: "text-red-500", - title: "Processing failed", - body: "Something went wrong during processing. Contact a Domna representative for assistance.", - cta: false, - }, -} as const; - -export default async function BulkUploadDetailPage(props: { - params: Promise<{ slug: string; uploadId: string }>; -}) { - const { slug, uploadId } = await props.params; - const session = await getServerSession(AuthOptions); - if (!session) redirect("/login"); - const isDomnaUser = !!session.user?.email?.endsWith("@domna.homes"); - - const [upload] = await db - .select() - .from(bulkAddressUploads) - .where(eq(bulkAddressUploads.id, uploadId)) - .limit(1); - - if (!upload) notFound(); - - const statusKey = upload.status as keyof typeof STATUS_CONFIG; - const config = STATUS_CONFIG[statusKey] ?? STATUS_CONFIG.ready_for_processing; - const Icon = config.icon; - - return ( -
- {/* Back */} - - - Back to uploads - - - {/* Header */} -
-

- Bulk Upload -

-

- {upload.filename} -

-

Uploaded {formatDate(upload.createdAt)}

-
- - {/* Status card */} -
-
-
- -
-
-

{config.title}

-

{config.body}

- - {statusKey === "ready_for_processing" && ( - - Map Columns - - - )} - - {statusKey === "mapping_complete" && ( -
- - Edit column mapping - - - -
- )} - - {(statusKey === "processing" || statusKey === "complete" || statusKey === "failed") && - upload.taskId && ( - - )} -
-
-
- -
- ); -} diff --git a/src/app/portfolio/[slug]/(portfolio)/bulk-upload/page.tsx b/src/app/portfolio/[slug]/(portfolio)/bulk-upload/page.tsx deleted file mode 100644 index cc5a949..0000000 --- a/src/app/portfolio/[slug]/(portfolio)/bulk-upload/page.tsx +++ /dev/null @@ -1,143 +0,0 @@ -"use server"; - -import { db } from "@/app/db/db"; -import { bulkAddressUploads } from "@/app/db/schema/bulk_address_uploads"; -import { eq, desc } from "drizzle-orm"; -import { getServerSession } from "next-auth"; -import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions"; -import { redirect } from "next/navigation"; -import Link from "next/link"; -import { - ArrowRightIcon, - DocumentTextIcon, - CloudArrowUpIcon, -} from "@heroicons/react/24/outline"; - -const STATUS_LABELS: Record = { - ready_for_processing: { label: "Ready", classes: "bg-amber-100 text-amber-700" }, - processing: { label: "Processing", classes: "bg-blue-100 text-blue-700" }, - complete: { label: "Complete", classes: "bg-green-100 text-green-700" }, - failed: { label: "Failed", classes: "bg-red-100 text-red-700" }, -}; - -function formatDate(date: Date) { - return new Intl.DateTimeFormat("en-GB", { - day: "2-digit", - month: "short", - year: "numeric", - hour: "2-digit", - minute: "2-digit", - }).format(date); -} - -export default async function BulkUploadListPage(props: { - params: Promise<{ slug: string }>; -}) { - const { slug } = await props.params; - const session = await getServerSession(AuthOptions); - if (!session) redirect("/login"); - - const uploads = await db - .select() - .from(bulkAddressUploads) - .where(eq(bulkAddressUploads.portfolioId, slug)) - .orderBy(desc(bulkAddressUploads.createdAt)); - - return ( -
- {/* Header */} -
-

- Portfolio › Bulk Uploads -

-

- Batch Uploads -

-

- Select an upload to continue processing, or start a new import. -

-
- - {uploads.length === 0 ? ( - /* Empty state */ -
-
- -
-

No uploads yet

-

- Use the Bulk Upload button on your portfolio to get started. -

-
- ) : ( - /* Upload list */ -
- {/* Column headers */} -
- - File - - - Uploaded - - - Status - - -
- - {uploads.map((upload) => { - const status = STATUS_LABELS[upload.status] ?? { - label: upload.status, - classes: "bg-gray-100 text-gray-600", - }; - return ( - - {/* Filename */} -
-
- -
-
-

- {upload.filename} -

-

- {upload.s3Key.split("/").pop()} -

-
-
- - {/* Date */} -
-

- {formatDate(upload.createdAt)} -

-
- - {/* Status badge */} -
- - - {status.label} - -
- - {/* Arrow */} -
- -
- - ); - })} -
- )} -
- ); -} diff --git a/src/app/portfolio/[slug]/(portfolio)/settings/CapabilitiesCard.tsx b/src/app/portfolio/[slug]/(portfolio)/settings/CapabilitiesCard.tsx deleted file mode 100644 index 533c2e3..0000000 --- a/src/app/portfolio/[slug]/(portfolio)/settings/CapabilitiesCard.tsx +++ /dev/null @@ -1,230 +0,0 @@ -"use client"; - -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/app/shadcn_components/ui/table"; -import { Button } from "@/app/shadcn_components/ui/button"; -import { Badge } from "@/app/shadcn_components/ui/badge"; -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; - -type Capability = "approver" | "contractor"; - -type CapabilityEntry = { - id: string; - userId: string; - capability: Capability; - name: string | null; - email: string; -}; - -type CapabilityMap = Record; - -async function getCapabilities(portfolioId: string): Promise { - const res = await fetch(`/api/portfolio/${portfolioId}/capabilities`); - if (!res.ok) throw new Error("Failed to fetch capabilities"); - return res.json(); -} - -async function getCollaborators( - portfolioId: string, -): Promise<{ userId: string; name: string | null; email: string }[]> { - const res = await fetch(`/api/portfolio/${portfolioId}/colloborators`); - if (!res.ok) throw new Error("Failed to fetch collaborators"); - const json = await res.json(); - const users = Array.isArray(json) ? json : json.users ?? []; - return users.map((u: any) => ({ - userId: String(u.userId), - name: u.name ?? null, - email: u.email ?? "", - })); -} - -async function assignCapability( - portfolioId: string, - userId: string, - capability: Capability, -): Promise { - const res = await fetch(`/api/portfolio/${portfolioId}/capabilities`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ userId, capability }), - }); - if (!res.ok) throw new Error("Failed to assign capability"); -} - -async function removeCapability( - portfolioId: string, - userId: string, - capability: Capability, -): Promise { - const res = await fetch(`/api/portfolio/${portfolioId}/capabilities`, { - method: "DELETE", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ userId, capability }), - }); - if (!res.ok) throw new Error("Failed to remove capability"); -} - -export function CapabilitiesCard({ portfolioId }: { portfolioId: string }) { - const queryClient = useQueryClient(); - const queryKey = ["portfolioCapabilities", portfolioId]; - - const { data: entries = [], isLoading: loadingCaps } = useQuery({ - queryKey, - queryFn: () => getCapabilities(portfolioId), - enabled: !!portfolioId, - refetchOnWindowFocus: false, - }); - - const { data: collaborators = [], isLoading: loadingCollabs } = useQuery({ - queryKey: ["portfolioUsers", portfolioId], - queryFn: () => getCollaborators(portfolioId), - enabled: !!portfolioId, - refetchOnWindowFocus: false, - }); - - const isLoading = loadingCaps || loadingCollabs; - - // Build a map: userId -> { capabilities: [] } - const capMap: CapabilityMap = {}; - for (const c of collaborators) { - capMap[c.userId] = { name: c.name, email: c.email, capabilities: [] }; - } - for (const e of entries) { - if (capMap[e.userId]) { - capMap[e.userId].capabilities.push(e.capability); - } - } - - const toggleMutation = useMutation({ - mutationFn: ({ - userId, - capability, - has, - }: { - userId: string; - capability: Capability; - has: boolean; - }) => - has - ? removeCapability(portfolioId, userId, capability) - : assignCapability(portfolioId, userId, capability), - onSettled: () => { - queryClient.invalidateQueries({ queryKey }); - }, - }); - - const rows = Object.entries(capMap); - - return ( -
- - - - - Project Roles: -

- Assign approver or contractor capabilities to users -

-
-
- - -
-
- - - Name - Email - Approver - Contractor - - - - {isLoading ? ( - - - Loading… - - - ) : rows.length === 0 ? ( - - - No collaborators yet. Add users in the section above first. - - - ) : ( - rows.map(([userId, { name, email, capabilities }]) => ( - - {name || "—"} - {email} - - - toggleMutation.mutate({ userId, capability: "approver", has }) - } - /> - - - - toggleMutation.mutate({ userId, capability: "contractor", has }) - } - /> - - - )) - )} - -
-
- - - - -
- ); -} - -function CapabilityToggle({ - has, - capability, - isPending, - onToggle, -}: { - has: boolean; - capability: Capability; - isPending: boolean; - onToggle: (has: boolean) => void; -}) { - return ( - - ); -} diff --git a/src/app/portfolio/[slug]/(portfolio)/settings/UsersPermissionsCard.tsx b/src/app/portfolio/[slug]/(portfolio)/settings/UsersPermissionsCard.tsx index 94fc425..dc2d5cf 100644 --- a/src/app/portfolio/[slug]/(portfolio)/settings/UsersPermissionsCard.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/settings/UsersPermissionsCard.tsx @@ -27,13 +27,15 @@ async function getPortfolioUsers(portfolioId: string): Promise { const users = Array.isArray(json) ? json : json.users; // support both shapes // Guard + shape to Collaborator[] return Array.isArray(users) - ? users.map((u: any) => ({ - portfolioUserId: String(u.portfolioUserId), - userId: String(u.userId), - name: u.name ?? null, - email: u.email ?? "", - role: u.role, - })) + ? users + .filter((u: any) => u.role !== "creator") // 👈 filter out creator + .map((u: any) => ({ + portfolioUserId: String(u.portfolioUserId), + userId: String(u.userId), + name: u.name ?? null, + email: u.email ?? "", + role: u.role, + })) : []; } @@ -249,20 +251,12 @@ export function UsersPermissionsCard({ portfolioId }: { portfolioId: string }) { {c.name || "—"} {c.email} - {c.role === "creator" || c.role === "admin" ? ( - - {c.role} - - ) : ( - onChangeRole(c.portfolioUserId, r)} /> - )} + onChangeRole(c.portfolioUserId, r)} /> - {c.role !== "creator" && ( - - )} + )) diff --git a/src/app/portfolio/[slug]/(portfolio)/settings/roles.tsx b/src/app/portfolio/[slug]/(portfolio)/settings/roles.tsx index 7fd570b..feeb223 100644 --- a/src/app/portfolio/[slug]/(portfolio)/settings/roles.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/settings/roles.tsx @@ -13,10 +13,10 @@ export type Role = typeof ROLE_OPTIONS[number]; export type Collaborator = { portfolioUserId: string; - userId: string; + userId: string; name?: string | null; email: string; - role: Role | "creator" | "admin"; + role: Role; }; // Small role dropdown using shadcn Select diff --git a/src/app/portfolio/[slug]/(portfolio)/settings/user-access/page.tsx b/src/app/portfolio/[slug]/(portfolio)/settings/user-access/page.tsx index ae98d36..5d749ab 100644 --- a/src/app/portfolio/[slug]/(portfolio)/settings/user-access/page.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/settings/user-access/page.tsx @@ -1,5 +1,4 @@ import { UsersPermissionsCard } from "../UsersPermissionsCard"; -import { CapabilitiesCard } from "../CapabilitiesCard"; export default async function UserAccessPage(props: { params: Promise<{ slug: string }>; @@ -9,7 +8,6 @@ export default async function UserAccessPage(props: { return (
-
); } diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/ApprovalConfirmDialog.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/ApprovalConfirmDialog.tsx deleted file mode 100644 index ded3a4a..0000000 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/ApprovalConfirmDialog.tsx +++ /dev/null @@ -1,140 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogDescription, - DialogFooter, -} from "@/app/shadcn_components/ui/dialog"; -import { Button } from "@/app/shadcn_components/ui/button"; -import { Input } from "@/app/shadcn_components/ui/input"; -import { CheckCircle2, XCircle } from "lucide-react"; - -export type PendingDiff = { - added: string[]; - removed: string[]; -}; - -type Props = { - open: boolean; - pendingDiffs: Record; // dealId -> diff - dealNames: Record; // dealId -> display name - onConfirm: () => void; - onCancel: () => void; - isPending: boolean; -}; - -const CONFIRM_WORD = "approve"; - -export function ApprovalConfirmDialog({ - open, - pendingDiffs, - dealNames, - onConfirm, - onCancel, - isPending, -}: Props) { - const [typed, setTyped] = useState(""); - - const canConfirm = typed === CONFIRM_WORD && !isPending; - - const totalAdded = Object.values(pendingDiffs).reduce( - (sum, d) => sum + d.added.length, - 0, - ); - const totalRemoved = Object.values(pendingDiffs).reduce( - (sum, d) => sum + d.removed.length, - 0, - ); - - function handleOpenChange(open: boolean) { - if (!open) { - setTyped(""); - onCancel(); - } - } - - return ( - - - - Confirm approval changes - - Review the changes below. This action will be recorded in the audit - log and cannot be undone automatically. - - - -
- {Object.entries(pendingDiffs).map(([dealId, diff]) => { - if (diff.added.length === 0 && diff.removed.length === 0) return null; - const name = dealNames[dealId] ?? dealId; - return ( -
-

{name}

-
- {diff.added.map((m) => ( -
- - {m} - will be approved -
- ))} - {diff.removed.map((m) => ( -
- - {m} - will be unapproved -
- ))} -
-
- ); - })} -
- -
-

- To confirm{" "} - - {totalAdded > 0 && `${totalAdded} approval${totalAdded > 1 ? "s" : ""}`} - {totalAdded > 0 && totalRemoved > 0 && " and "} - {totalRemoved > 0 && `${totalRemoved} removal${totalRemoved > 1 ? "s" : ""}`} - - , type{" "} - - {CONFIRM_WORD} - {" "} - below: -

- setTyped(e.target.value)} - placeholder={`Type "${CONFIRM_WORD}" to confirm`} - className="font-mono" - autoFocus - /> -
- - - - - -
-
- ); -} diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/ContractorUploadModal.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/ContractorUploadModal.tsx deleted file mode 100644 index 0d6de22..0000000 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/ContractorUploadModal.tsx +++ /dev/null @@ -1,569 +0,0 @@ -"use client"; - -import { useEffect, useRef, useState } from "react"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogDescription, - DialogFooter, -} from "@/app/shadcn_components/ui/dialog"; -import { Button } from "@/app/shadcn_components/ui/button"; -import { - Select, - SelectContent, - SelectGroup, - SelectItem, - SelectLabel, - SelectTrigger, - SelectValue, -} from "@/app/shadcn_components/ui/select"; -import { CheckCircle2, XCircle, Upload, Loader2, Clock } from "lucide-react"; -import { uploadFileToS3 } from "@/app/utils/s3"; -import type { ClassifiedDeal } from "./types"; - -// ── Types ───────────────────────────────────────────────────────────────── - -type FileStatus = "queued" | "uploading" | "done" | "error"; - -type FileEntry = { - id: string; // local UUID for React key - // One of these will be set: - file?: File; // for newly picked files - existingS3Key?: string; // for pre-existing unclassified uploads - // Display - displayName: string; - displaySize?: string; - // Upload state - status: FileStatus; - errorMsg?: string; - uploadedId?: string; // DB record ID (set after recording or from existing) - // Classification - docType: string; - measureName: string; -}; - -type Phase = "loading" | "upload" | "classify"; - -type Props = { - deal: ClassifiedDeal; - portfolioId: string; - onClose: () => void; -}; - -// ── Constants ───────────────────────────────────────────────────────────── - -const FILE_TYPE_OPTIONS: { value: string; label: string; group: string }[] = [ - { value: "pre_photo", label: "Pre Photo", group: "Install Photos" }, - { value: "mid_photo", label: "Mid Photo", group: "Install Photos" }, - { value: "post_photo", label: "Post Photo", group: "Install Photos" }, - { value: "pre_installation_building_inspection", label: "Pre-Installation Building Inspection (PIBI)", group: "Pre-Installation" }, - { value: "point_of_work_risk_assessment", label: "Point of Work Risk Assessment", group: "Pre-Installation" }, - { value: "claim_of_compliance", label: "Claim of Compliance (PAS 2030)", group: "Compliance" }, - { value: "mcs_compliance_certificate", label: "MCS Compliance Certificate", group: "Compliance" }, - { value: "certificate_of_conformity", label: "Certificate of Conformity", group: "Compliance" }, - { value: "minor_works_electrical_certificate", label: "Minor Works Electrical Certificate", group: "Compliance" }, - { value: "handover_pack", label: "Handover Documents / Pack", group: "Handover" }, - { value: "workmanship_warranty", label: "Workmanship Warranty", group: "Handover" }, - { value: "insurance_guarantee", label: "Insurance Backed Guarantee (IBG)", group: "Handover" }, - { value: "g98_notification", label: "G98 / G99 Notification", group: "Handover" }, - { value: "ventilation_assessment_checklist", label: "Ventilation Assessment Checklist", group: "Handover" }, - { value: "installer_qualifications", label: "Installer Qualifications", group: "Qualifications" }, - { value: "installer_feedback", label: "Installer Feedback", group: "Other" }, - { value: "contractor_other", label: "Other", group: "Other" }, -]; - -const FILE_TYPE_GROUPS = ["Install Photos", "Pre-Installation", "Compliance", "Handover", "Qualifications", "Other"]; - -// ── Helpers ─────────────────────────────────────────────────────────────── - -function formatSize(bytes: number): string { - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`; - return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; -} - -function contentTypeFor(ext: string): string { - const e = ext.toLowerCase(); - if (e === "pdf") return "application/pdf"; - if (["jpg", "jpeg"].includes(e)) return "image/jpeg"; - if (e === "png") return "image/png"; - return "application/octet-stream"; -} - -function parseMeasures(raw: string | null | undefined): string[] { - if (!raw) return []; - return raw.split(",").map((m) => m.trim()).filter(Boolean); -} - -function s3KeyBasename(key: string): string { - return key.split("/").pop() ?? key; -} - -async function getPresignedUrl(path: string, contentType: string): Promise { - const res = await fetch("/api/upload/retrofit-energy-assessments", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ path, contentType, expiresInSeconds: 300 }), - }); - if (!res.ok) throw new Error("Failed to get presigned URL"); - const { url } = await res.json(); - return url; -} - -async function recordUpload(payload: { - s3FileKey: string; - s3FileBucket: string; - uprn?: string; - hubspotDealId?: string; - landlordPropertyId?: string; -}): Promise { - const res = await fetch("/api/upload/contractor-install", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }); - if (!res.ok) throw new Error("Failed to record upload"); - const { id } = await res.json(); - return id; -} - -async function saveClassifications( - updates: { id: string; fileType: string; measureName?: string }[], -): Promise { - const res = await fetch("/api/upload/contractor-install", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ updates }), - }); - if (!res.ok) throw new Error("Failed to save classifications"); -} - -// ── DocType select ───────────────────────────────────────────────────────── - -function DocTypeSelect({ value, onChange }: { value: string; onChange: (v: string) => void }) { - return ( - - ); -} - -// ── Status icon ──────────────────────────────────────────────────────────── - -function StatusIcon({ status, isExisting, errorMsg }: { status: FileStatus; isExisting?: boolean; errorMsg?: string }) { - if (isExisting) return ; - if (status === "queued") return ; - if (status === "uploading") return ; - if (status === "done") return ; - return ; -} - -// ── Main component ───────────────────────────────────────────────────────── - -export default function ContractorUploadModal({ deal, portfolioId, onClose }: Props) { - const measures = parseMeasures(deal.proposedMeasures); - const fileInputRef = useRef(null); - const [isDragOver, setIsDragOver] = useState(false); - const [queue, setQueue] = useState([]); - const [phase, setPhase] = useState("loading"); - const [isUploading, setIsUploading] = useState(false); - const [isSaving, setIsSaving] = useState(false); - const [saveError, setSaveError] = useState(null); - - // ── Fetch existing unclassified files on mount ─────────────────────── - - useEffect(() => { - async function fetchExisting() { - const uprnParam = deal.uprn; - const propIdParam = deal.landlordPropertyId; - if (!uprnParam && !propIdParam) { - setPhase("upload"); - return; - } - - try { - const param = uprnParam - ? `uprn=${encodeURIComponent(uprnParam)}` - : `landlordPropertyId=${encodeURIComponent(propIdParam!)}`; - const res = await fetch(`/api/live-tracking/property-documents?${param}`); - if (!res.ok) throw new Error("fetch failed"); - const docs: { id: string; s3FileKey: string; docType: string | null; source: string | null }[] = await res.json(); - - const unclassified = docs.filter( - (d) => d.source === "contractor" && (d.docType === null || d.docType === "unknown"), - ); - - if (unclassified.length > 0) { - const entries: FileEntry[] = unclassified.map((d) => ({ - id: crypto.randomUUID(), - existingS3Key: d.s3FileKey, - displayName: s3KeyBasename(d.s3FileKey), - status: "done", - uploadedId: d.id, - docType: "", - measureName: measures[0] ?? "", - })); - setQueue(entries); - setPhase("classify"); - } else { - setPhase("upload"); - } - } catch { - // If fetch fails, just proceed to upload phase - setPhase("upload"); - } - } - - fetchExisting(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - // ── File selection ─────────────────────────────────────────────────── - - function addFiles(files: FileList | File[]) { - const newEntries: FileEntry[] = Array.from(files).map((f) => ({ - id: crypto.randomUUID(), - file: f, - displayName: f.name, - displaySize: formatSize(f.size), - status: "queued", - docType: "", - measureName: measures[0] ?? "", - })); - setQueue((prev) => [...prev, ...newEntries]); - } - - function handleInputChange(e: React.ChangeEvent) { - if (e.target.files?.length) addFiles(e.target.files); - e.target.value = ""; - } - - function handleDrop(e: React.DragEvent) { - e.preventDefault(); - setIsDragOver(false); - if (e.dataTransfer.files?.length) addFiles(e.dataTransfer.files); - } - - function removeFile(id: string) { - setQueue((prev) => prev.filter((f) => f.id !== id)); - } - - // ── Phase 1: Upload new files ──────────────────────────────────────── - - async function handleUpload() { - const toUpload = queue.filter((f) => f.status === "queued"); - if (toUpload.length === 0) { - // No new files to upload — go straight to classify for existing - setPhase("classify"); - return; - } - if (isUploading) return; - setIsUploading(true); - - setQueue((prev) => - prev.map((f) => f.status === "queued" ? { ...f, status: "uploading" } : f), - ); - - const uploadResults = await Promise.allSettled( - toUpload.map(async (entry) => { - const ext = (entry.file!.name.split(".").pop() ?? "bin").toLowerCase(); - const ct = contentTypeFor(ext); - const timestamp = Date.now(); - const s3Key = `contractor-install/${deal.dealId}/unclassified/${timestamp}_${entry.id.slice(0, 8)}.${ext}`; - - const presignedUrl = await getPresignedUrl(s3Key, ct); - await uploadFileToS3({ presignedUrl, file: entry.file!, contentType: ct }); - - const urlObj = new URL(presignedUrl); - const bucket = urlObj.hostname.split(".")[0]; - - const uploadedId = await recordUpload({ - s3FileKey: s3Key, - s3FileBucket: bucket, - uprn: deal.uprn ?? undefined, - hubspotDealId: deal.dealId, - landlordPropertyId: deal.landlordPropertyId ?? undefined, - }); - - return { id: entry.id, uploadedId }; - }), - ); - - const resultMap = new Map( - uploadResults.map((r, i) => [ - toUpload[i].id, - r.status === "fulfilled" ? { ok: true, uploadedId: r.value.uploadedId } : { ok: false }, - ]), - ); - - setQueue((prev) => - prev.map((f) => { - const r = resultMap.get(f.id); - if (!r) return f; - if (r.ok) return { ...f, status: "done", uploadedId: r.uploadedId }; - return { ...f, status: "error", errorMsg: "Upload failed" }; - }), - ); - - setIsUploading(false); - setPhase("classify"); - } - - // ── Phase 2: Classify ──────────────────────────────────────────────── - - function updateEntryField(id: string, field: "docType" | "measureName", value: string) { - setQueue((prev) => prev.map((f) => (f.id === id ? { ...f, [field]: value } : f))); - } - - const classifiableEntries = queue.filter((f) => f.status === "done" && f.uploadedId); - const allClassified = classifiableEntries.length > 0 && classifiableEntries.every((f) => f.docType !== ""); - - async function handleSaveClassifications() { - setSaveError(null); - setIsSaving(true); - try { - await saveClassifications( - classifiableEntries.map((f) => ({ - id: f.uploadedId!, - fileType: f.docType, - measureName: f.measureName || undefined, - })), - ); - onClose(); - } catch { - setSaveError("Failed to save classifications. Please try again."); - } finally { - setIsSaving(false); - } - } - - // ── Computed ───────────────────────────────────────────────────────── - - const newQueuedCount = queue.filter((f) => f.status === "queued").length; - const existingCount = queue.filter((f) => f.existingS3Key && f.status === "done").length; - const propertyLabel = deal.dealname ?? deal.landlordPropertyId ?? deal.dealId; - - // ── Render ─────────────────────────────────────────────────────────── - - return ( - - - - - {phase === "loading" ? "Loading…" : - phase === "upload" ? "Upload Documents" : - "Classify Documents"} - - - {phase === "loading" && "Checking for pending files…"} - {phase === "upload" && ( - <> - Upload install documents for {propertyLabel}. - {existingCount > 0 && ` ${existingCount} file${existingCount !== 1 ? "s" : ""} are pending classification.`} - - )} - {phase === "classify" && ( - <> - {classifiableEntries.length} file{classifiableEntries.length !== 1 ? "s" : ""} ready to classify. - Select a document type for each, then save. - - )} - - - -
- - {/* ── Loading ── */} - {phase === "loading" && ( -
- -
- )} - - {/* ── Phase 1: Upload ── */} - {phase === "upload" && ( - <> - {/* Existing unclassified banner */} - {existingCount > 0 && ( -
- - - {existingCount} previously uploaded file{existingCount !== 1 ? "s" : ""} {existingCount !== 1 ? "are" : "is"} waiting to be classified. - Add new files or go straight to classification. - -
- )} - - {/* Drop zone */} -
fileInputRef.current?.click()} - onDragOver={(e) => { e.preventDefault(); setIsDragOver(true); }} - onDragLeave={() => setIsDragOver(false)} - onDrop={handleDrop} - > - -

Drop files here or click to browse

-

PDF, JPG, PNG accepted · Multiple files OK

- -
- - {/* New file queue */} - {newQueuedCount > 0 && ( -
- {queue.filter((f) => f.file).map((entry) => ( -
-
-

{entry.displayName}

- {entry.displaySize &&

{entry.displaySize}

} -
- - {entry.status === "queued" && ( - - )} -
- ))} -
- )} - - )} - - {/* ── Phase 2: Classify ── */} - {phase === "classify" && ( -
- {/* Column headers */} -
- File - Document Type * - Measure -
- - {classifiableEntries.map((entry) => ( -
-
- -
-

{entry.displayName}

- {entry.displaySize &&

{entry.displaySize}

} - {entry.existingS3Key &&

Previously uploaded

} -
-
- updateEntryField(entry.id, "docType", v)} /> - {measures.length > 0 ? ( - - ) : ( - - )} -
- ))} - - {/* Failed uploads (info only) */} - {queue.filter((f) => f.status === "error").length > 0 && ( -
-

- {queue.filter((f) => f.status === "error").length} file(s) failed and are excluded: -

- {queue.filter((f) => f.status === "error").map((f) => ( -

{f.displayName}

- ))} -
- )} - - {saveError && ( -

- {saveError} -

- )} -
- )} -
- - - {phase === "loading" && ( - - )} - - {phase === "upload" && ( - <> - - - - )} - - {phase === "classify" && ( - <> - - - - )} - -
-
- ); -} diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DocumentTable.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DocumentTable.tsx index 195b534..b89ba9b 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DocumentTable.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DocumentTable.tsx @@ -28,8 +28,7 @@ import { } from "@/app/shadcn_components/ui/select"; 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 } from "./types"; type SurveyStatusFilter = "all" | "none" | "partial" | "complete"; @@ -37,8 +36,6 @@ interface DocumentTableProps { data: ClassifiedDeal[]; onOpenDrawer: (uprn: string | null, landlordPropertyId: string | null, dealname: string | null) => void; docStatusMap: DocStatusMap; - portfolioId: string; - userCapability: PortfolioCapabilityType; } function escapeCell(value: unknown): string { @@ -52,7 +49,7 @@ function escapeCell(value: unknown): string { : str; } -export default function DocumentTable({ data, onOpenDrawer, docStatusMap, portfolioId, userCapability }: DocumentTableProps) { +export default function DocumentTable({ data, onOpenDrawer, docStatusMap }: DocumentTableProps) { const [globalFilter, setGlobalFilter] = useState(""); const [surveyStatusFilter, setSurveyStatusFilter] = useState("all"); const [sorting, setSorting] = useState([]); @@ -60,7 +57,6 @@ export default function DocumentTable({ data, onOpenDrawer, docStatusMap, portfo pageIndex: 0, pageSize: 25, }); - const [uploadDeal, setUploadDeal] = useState(null); const filteredData = useMemo(() => { if (surveyStatusFilter === "all") return data; @@ -74,12 +70,8 @@ export default function DocumentTable({ data, onOpenDrawer, docStatusMap, portfo }, [data, surveyStatusFilter, docStatusMap]); const columns = useMemo( - () => createDocumentTableColumns( - onOpenDrawer, - docStatusMap, - userCapability.includes("contractor") ? setUploadDeal : undefined, - ), - [onOpenDrawer, docStatusMap, userCapability], + () => createDocumentTableColumns(onOpenDrawer, docStatusMap), + [onOpenDrawer, docStatusMap], ); const table = useReactTable({ @@ -247,15 +239,6 @@ export default function DocumentTable({ data, onOpenDrawer, docStatusMap, portfo
- {/* Contractor upload modal */} - {uploadDeal && ( - setUploadDeal(null)} - /> - )} - {/* Pagination */} {pageCount > 1 && (
diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DocumentTableColumns.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DocumentTableColumns.tsx index 1b762c9..f88514d 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DocumentTableColumns.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DocumentTableColumns.tsx @@ -1,7 +1,7 @@ "use client"; import { ColumnDef } from "@tanstack/react-table"; -import { ArrowUpDown, CheckCircle2, AlertCircle, FileX, Upload } from "lucide-react"; +import { ArrowUpDown, CheckCircle2, AlertCircle, FileX } from "lucide-react"; import type { ClassifiedDeal, DocStatusMap, DocStatus } from "./types"; function SortableHeader({ @@ -50,7 +50,6 @@ function SurveyStatusBadge({ status }: { status: DocStatus | undefined }) { export function createDocumentTableColumns( onOpenDrawer: (uprn: string | null, landlordPropertyId: string | null, dealname: string | null) => void, docStatusMap: DocStatusMap = {}, - onUpload?: (deal: ClassifiedDeal) => void, ): ColumnDef[] { return [ // ── Address ────────────────────────────────────────────────────────── @@ -144,24 +143,5 @@ export function createDocumentTableColumns( enableSorting: false, enableHiding: false, }, - - // ── Upload button (contractor only) ────────────────────────────────── - ...(onUpload ? [{ - id: "upload", - header: () => ( - Upload - ), - cell: ({ row }: { row: { original: ClassifiedDeal } }) => ( - - ), - enableSorting: false, - enableHiding: false, - } as ColumnDef] : []), ]; } diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/LiveTracker.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/LiveTracker.tsx index 2bb2a4f..502bd71 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/LiveTracker.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/LiveTracker.tsx @@ -9,11 +9,10 @@ import { TabsTrigger, } from "@/app/shadcn_components/ui/tabs"; import { Card, CardContent } from "@/app/shadcn_components/ui/card"; -import { BarChart2, Table2, FolderOpen, Wrench } from "lucide-react"; +import { BarChart2, Table2, FolderOpen } from "lucide-react"; import DrillDownTable from "./DrillDownTable"; import PropertyTable from "./PropertyTable"; import DocumentTable from "./DocumentTable"; -import MeasuresTable from "./MeasuresTable"; import type { HubspotDeal } from "./types"; import PropertyDrawer from "./PropertyDrawer"; import PropertyDetailDrawer from "./PropertyDetailDrawer"; @@ -31,12 +30,9 @@ export default function LiveTracker({ totalDeals, majorConditionDeals, docStatusMap, - userCapability, - approvalsByDeal, - portfolioId, }: LiveTrackerProps) { // ── Tab state ──────────────────────────────────────────────────────── - const [activeTab, setActiveTab] = useState<"analytics" | "properties" | "documents" | "measures">( + const [activeTab, setActiveTab] = useState<"analytics" | "properties" | "documents">( "analytics", ); @@ -98,7 +94,7 @@ export default function LiveTracker({
setActiveTab(v as "analytics" | "properties" | "documents" | "measures")} + onValueChange={(v) => setActiveTab(v as "analytics" | "properties" | "documents")} > {/* Tab bar */} @@ -123,13 +119,6 @@ export default function LiveTracker({ Document Management - - - Measures - {/* Analytics tab */} @@ -215,42 +204,6 @@ export default function LiveTracker({ data={currentProject?.allDeals ?? []} onOpenDrawer={handleOpenDrawer} docStatusMap={docStatusMap} - portfolioId={portfolioId} - userCapability={userCapability} - /> -
- - - {/* Measures tab */} - -
- {projects.length > 1 && ( -
- Project: - -
- )} -
@@ -358,7 +311,6 @@ export default function LiveTracker({ {/* ── Property detail drawer ─────────────────────────────────────── */} setDetailDeal(null)} />
diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/MeasuresTable.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/MeasuresTable.tsx deleted file mode 100644 index f7e3414..0000000 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/MeasuresTable.tsx +++ /dev/null @@ -1,469 +0,0 @@ -"use client"; - -import React, { useMemo, useState } from "react"; -import { useMutation, useQuery } from "@tanstack/react-query"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/app/shadcn_components/ui/table"; -import { Input } from "@/app/shadcn_components/ui/input"; -import { Button } from "@/app/shadcn_components/ui/button"; -import { Badge } from "@/app/shadcn_components/ui/badge"; -import { Checkbox } from "@/app/shadcn_components/ui/checkbox"; -import { Search, Save, ChevronDown, ChevronRight } from "lucide-react"; -import { STAGE_COLORS } from "./types"; -import type { ClassifiedDeal, PortfolioCapabilityType, ApprovalsByDeal } from "./types"; -import { ApprovalConfirmDialog, type PendingDiff } from "./ApprovalConfirmDialog"; - -type AuditEvent = { - id: string; - hubspotDealId: string; - measureName: string; - action: string; // 'approved' | 'unapproved' - actedByEmail: string; - actedByName: string | null; - actedAt: string; // ISO string -}; - -type Props = { - data: ClassifiedDeal[]; - userCapability: PortfolioCapabilityType; - approvalsByDeal: ApprovalsByDeal; - portfolioId: string; -}; - -function parseMeasures(raw: string | null | undefined): string[] { - if (!raw) return []; - return raw.split(",").map((m) => m.trim()).filter(Boolean); -} - -function ApprovalStatus({ - proposed, - approved, -}: { - proposed: string[]; - approved: string[]; -}) { - if (proposed.length === 0) return null; - const approvedSet = new Set(approved); - const approvedCount = proposed.filter((m) => approvedSet.has(m)).length; - - if (approvedCount === 0) { - return ( - - Pending - - ); - } - if (approvedCount === proposed.length) { - return ( - - Fully Approved - - ); - } - return ( - - {approvedCount}/{proposed.length} Approved - - ); -} - -function formatDate(iso: string) { - return new Date(iso).toLocaleString("en-GB", { - day: "numeric", - month: "short", - year: "numeric", - hour: "2-digit", - minute: "2-digit", - }); -} - -function ActivityLog({ - dealId, - portfolioId, -}: { - dealId: string; - portfolioId: string; -}) { - const { data, isLoading } = useQuery<{ events: AuditEvent[] }>({ - queryKey: ["approvalEvents", portfolioId, dealId], - queryFn: async () => { - const res = await fetch( - `/api/portfolio/${portfolioId}/approvals?dealIds=${dealId}&include=events`, - ); - if (!res.ok) throw new Error("Failed to fetch events"); - return res.json(); - }, - staleTime: 30_000, - }); - - if (isLoading) { - return ( -

Loading activity…

- ); - } - - const events = data?.events ?? []; - - if (events.length === 0) { - return ( -

No activity yet.

- ); - } - - return ( -
- {events.map((e) => ( -
- - {e.action === "approved" ? "Approved" : "Unapproved"} - - {e.measureName} - · - - {e.actedByName ?? e.actedByEmail} - - · - {formatDate(e.actedAt)} -
- ))} -
- ); -} - -async function postApprovalChanges( - portfolioId: string, - changes: { hubspotDealId: string; measureName: string; approved: boolean }[], -) { - const res = await fetch(`/api/portfolio/${portfolioId}/approvals`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ changes }), - }); - if (!res.ok) throw new Error("Failed to save approvals"); -} - -export default function MeasuresTable({ - data, - userCapability, - approvalsByDeal, - portfolioId, -}: Props) { - const [search, setSearch] = useState(""); - // pendingChanges: dealId -> desired Set (the full intended approved set) - const [pendingChanges, setPendingChanges] = useState< - Record> - >({}); - const [savedApprovals, setSavedApprovals] = - useState(approvalsByDeal); - const [showConfirm, setShowConfirm] = useState(false); - const [expandedRows, setExpandedRows] = useState>(new Set()); - - // Filter to only properties with proposed measures - const dealsWithMeasures = useMemo( - () => data.filter((d) => d.proposedMeasures), - [data], - ); - - const filtered = useMemo(() => { - const q = search.toLowerCase(); - if (!q) return dealsWithMeasures; - return dealsWithMeasures.filter( - (d) => - d.dealname?.toLowerCase().includes(q) || - d.landlordPropertyId?.toLowerCase().includes(q) || - d.proposedMeasures?.toLowerCase().includes(q), - ); - }, [dealsWithMeasures, search]); - - const hasPendingChanges = Object.keys(pendingChanges).length > 0; - - // Compute diffs: for each deal in pendingChanges, what's added vs removed vs saved - const pendingDiffs = useMemo>(() => { - const diffs: Record = {}; - for (const [dealId, pending] of Object.entries(pendingChanges)) { - const saved = new Set(savedApprovals[dealId] ?? []); - const added = [...pending].filter((m) => !saved.has(m)); - const removed = [...saved].filter((m) => !pending.has(m)); - if (added.length > 0 || removed.length > 0) { - diffs[dealId] = { added, removed }; - } - } - return diffs; - }, [pendingChanges, savedApprovals]); - - const dealNames = useMemo>(() => { - const map: Record = {}; - for (const d of dealsWithMeasures) { - map[d.dealId] = d.dealname ?? d.landlordPropertyId ?? d.dealId; - } - return map; - }, [dealsWithMeasures]); - - const saveMutation = useMutation({ - mutationFn: () => { - // Build flat list of explicit changes from diffs - const changes: { hubspotDealId: string; measureName: string; approved: boolean }[] = []; - for (const [dealId, diff] of Object.entries(pendingDiffs)) { - for (const m of diff.added) changes.push({ hubspotDealId: dealId, measureName: m, approved: true }); - for (const m of diff.removed) changes.push({ hubspotDealId: dealId, measureName: m, approved: false }); - } - return postApprovalChanges(portfolioId, changes); - }, - onSuccess: () => { - setSavedApprovals((prev) => { - const next = { ...prev }; - for (const [dealId, pending] of Object.entries(pendingChanges)) { - next[dealId] = Array.from(pending); - } - return next; - }); - setPendingChanges({}); - setShowConfirm(false); - }, - }); - - function toggleMeasure(dealId: string, measure: string) { - setPendingChanges((prev) => { - const base = - prev[dealId] !== undefined - ? new Set(prev[dealId]) - : new Set(savedApprovals[dealId] ?? []); - - if (base.has(measure)) { - base.delete(measure); - } else { - base.add(measure); - } - - // If pending equals saved, remove from tracking - const saved = new Set(savedApprovals[dealId] ?? []); - const equal = base.size === saved.size && [...base].every((m) => saved.has(m)); - - const next = { ...prev }; - if (equal) { - delete next[dealId]; - } else { - next[dealId] = base; - } - return next; - }); - } - - function toggleRowExpand(dealId: string) { - setExpandedRows((prev) => { - const next = new Set(prev); - if (next.has(dealId)) next.delete(dealId); - else next.add(dealId); - return next; - }); - } - - if (dealsWithMeasures.length === 0) { - return ( -
-

- No properties with proposed measures found in this project. -

-
- ); - } - - return ( -
- {/* Toolbar */} -
-
- - setSearch(e.target.value)} - className="pl-9 h-9 text-sm" - /> -
-
- - {filtered.length} of {dealsWithMeasures.length} properties - - {userCapability.includes("approver") && hasPendingChanges && ( - - )} -
-
- - {/* Table */} -
- - - - - - Address - - - Stage - - - Proposed Measures - - - Status - - - - - {filtered.map((deal) => { - const proposed = parseMeasures(deal.proposedMeasures); - const approvedForDeal = - pendingChanges[deal.dealId] !== undefined - ? Array.from(pendingChanges[deal.dealId]) - : (savedApprovals[deal.dealId] ?? []); - const approvedSet = new Set(approvedForDeal); - const stageColor = STAGE_COLORS[deal.displayStage]; - const hasPending = pendingChanges[deal.dealId] !== undefined; - const isExpanded = expandedRows.has(deal.dealId); - - return ( - - - {/* Expand toggle */} - - - - - {/* Address */} - -
- {deal.dealname ?? "—"} -
- {deal.landlordPropertyId && ( -
- {deal.landlordPropertyId} -
- )} -
- - {/* Stage */} - - - - {deal.displayStage} - - - - {/* Proposed measures */} - -
- {proposed.map((measure) => { - const isApproved = approvedSet.has(measure); - if (userCapability.includes("approver")) { - return ( - - ); - } - return ( - - {measure} - - ); - })} -
-
- - {/* Status */} - - - - -
- - {/* Expandable activity log row */} - {isExpanded && ( - - -
-

- Activity log -

- -
-
-
- )} -
- ); - })} -
-
-
- - {/* Confirmation dialog */} - saveMutation.mutate()} - onCancel={() => setShowConfirm(false)} - isPending={saveMutation.isPending} - /> - -
- ); -} diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyDetailDrawer.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyDetailDrawer.tsx index 0ca08ce..cbe9c9d 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyDetailDrawer.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyDetailDrawer.tsx @@ -1,9 +1,7 @@ "use client"; -import { useState } from "react"; -import { useQuery } from "@tanstack/react-query"; import { motion, AnimatePresence } from "framer-motion"; -import { X, CheckCircle2, Circle, AlertTriangle, ChevronRight, ChevronDown } from "lucide-react"; +import { X, CheckCircle2, Circle, AlertTriangle } from "lucide-react"; import { Drawer, DrawerClose, @@ -15,84 +13,6 @@ import { import { STAGE_COLORS } from "./types"; import type { ClassifiedDeal } from "./types"; -// ----------------------------------------------------------------------- -// Approval log types + helpers -// ----------------------------------------------------------------------- -type AuditEvent = { - id: string; - measureName: string; - action: string; // 'approved' | 'unapproved' - actedByEmail: string; - actedByName: string | null; - actedAt: string; -}; - -function formatDateTime(iso: string) { - return new Date(iso).toLocaleString("en-GB", { - day: "numeric", - month: "short", - year: "numeric", - hour: "2-digit", - minute: "2-digit", - }); -} - -function ApprovalLogSection({ - dealId, - portfolioId, -}: { - dealId: string; - portfolioId: string; -}) { - const { data, isLoading } = useQuery<{ events: AuditEvent[] }>({ - queryKey: ["approvalEvents", portfolioId, dealId], - queryFn: async () => { - const res = await fetch( - `/api/portfolio/${portfolioId}/approvals?dealIds=${dealId}&include=events`, - ); - if (!res.ok) throw new Error("Failed to fetch events"); - return res.json(); - }, - staleTime: 30_000, - }); - - if (isLoading) { - return

Loading activity…

; - } - - const events = data?.events ?? []; - - if (events.length === 0) { - return ( -

No approval activity yet.

- ); - } - - return ( -
- {events.map((e) => ( -
- - {e.action === "approved" ? "Approved" : "Unapproved"} - -
- {e.measureName} -
- {e.actedByName ?? e.actedByEmail} · {formatDateTime(e.actedAt)} -
-
-
- ))} -
- ); -} - // ----------------------------------------------------------------------- // Milestone definitions — ordered pipeline steps with their date fields // ----------------------------------------------------------------------- @@ -221,16 +141,14 @@ function MilestoneTimeline({ deal }: { deal: ClassifiedDeal }) { // ----------------------------------------------------------------------- interface PropertyDetailDrawerProps { deal: ClassifiedDeal | null; - portfolioId: string; onClose: () => void; } -export default function PropertyDetailDrawer({ deal, portfolioId, onClose }: PropertyDetailDrawerProps) { +export default function PropertyDetailDrawer({ deal, onClose }: PropertyDetailDrawerProps) { const open = !!deal; - const [isLogOpen, setIsLogOpen] = useState(false); return ( - { if (!v) { setIsLogOpen(false); onClose(); } }} direction="right"> + !v && onClose()} direction="right">
@@ -337,28 +255,6 @@ export default function PropertyDetailDrawer({ deal, portfolioId, onClose }: Pro

Project Timeline

- - {/* Approval log — collapsible */} -
- - {isLogOpen && ( -
- -
- )} -
{/* Footer */} diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/page.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/page.tsx index 0cbc869..f7002fc 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/page.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/page.tsx @@ -1,7 +1,7 @@ import { getServerSession } from "next-auth"; import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions"; import { redirect } from "next/navigation"; -import { and, eq, inArray } from "drizzle-orm"; +import { eq, inArray } from "drizzle-orm"; import LiveTracker from "./LiveTracker"; import { computeLiveTrackerData } from "./transforms"; import { db } from "@/app/db/db"; @@ -9,10 +9,7 @@ import { hubspotDealData } from "@/app/db/schema/crm/hubspot_deal_table"; import { uploadedFiles } from "@/app/db/schema/uploaded_files"; import { portfolioOrganisation } from "@/app/db/schema/portfolio_organisation"; import { organisation } from "@/app/db/schema/organisation"; -import { portfolioCapabilities } from "@/app/db/schema/portfolio"; -import { dealMeasureApprovals } from "@/app/db/schema/approvals"; -import { user as userTable } from "@/app/db/schema/users"; -import type { HubspotDeal, DocStatusMap, DocStatus, PortfolioCapabilityType, ApprovalsByDeal } from "./types"; +import type { HubspotDeal, DocStatusMap, DocStatus } from "./types"; import { EXPECTED_SURVEY_DOC_TYPES } from "./types"; import type { InferSelectModel } from "drizzle-orm"; import { Card, CardContent } from "@/app/shadcn_components/ui/card"; @@ -123,54 +120,6 @@ export default async function LiveReportingPage(props: { const deals = rawDeals.map(mapDbRowToHubspotDeal); const trackerData = computeLiveTrackerData(deals); - // Fetch current user's portfolio capabilities (approver / contractor — can have both) - let userCapability: PortfolioCapabilityType = []; - const userEmail = user?.user?.email; - if (userEmail) { - const userRow = await db - .select({ id: userTable.id }) - .from(userTable) - .where(eq(userTable.email, userEmail)) - .limit(1); - - if (userRow[0]) { - const capRows = await db - .select({ capability: portfolioCapabilities.capability }) - .from(portfolioCapabilities) - .where( - and( - eq(portfolioCapabilities.portfolioId, BigInt(portfolioId)), - eq(portfolioCapabilities.userId, userRow[0].id), - ), - ); - userCapability = capRows - .map((r) => r.capability) - .filter((c): c is "approver" | "contractor" => c === "approver" || c === "contractor"); - } - } - - // Fetch currently approved measures for all deals in scope - const approvalsByDeal: ApprovalsByDeal = {}; - const dealIds = deals.map((d) => d.dealId).filter(Boolean); - if (dealIds.length > 0) { - const approvalRows = await db - .select({ - hubspotDealId: dealMeasureApprovals.hubspotDealId, - measureName: dealMeasureApprovals.measureName, - }) - .from(dealMeasureApprovals) - .where( - and( - inArray(dealMeasureApprovals.hubspotDealId, dealIds), - eq(dealMeasureApprovals.isApproved, true), - ), - ); - - for (const row of approvalRows) { - (approvalsByDeal[row.hubspotDealId] ??= []).push(row.measureName); - } - } - // Fetch survey document status for all properties const uprnList = deals .map((d) => d.uprn) @@ -209,13 +158,7 @@ export default async function LiveReportingPage(props: { return (
{pageHeader} - +
); } diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/transforms.ts b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/transforms.ts index b829c91..fe1da87 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/transforms.ts +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/transforms.ts @@ -375,7 +375,7 @@ export function computeOutcomeSlices(deals: ClassifiedDeal[]): OutcomeSlice[] { // ----------------------------------------------------------------------- export function computeLiveTrackerData( rawDeals: HubspotDeal[] -): Omit { +): Omit { // Classify all deals (add displayStage field) const classified = classifyDeals(rawDeals); diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/types.ts b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/types.ts index a244f68..40fa764 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/types.ts +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/types.ts @@ -161,14 +161,6 @@ export type ProjectData = { allDeals: ClassifiedDeal[]; // for table drill-downs within project }; -// ----------------------------------------------------------------------- -// Portfolio capability for the current viewing user -// ----------------------------------------------------------------------- -export type PortfolioCapabilityType = ("approver" | "contractor")[]; - -// Approved measure names per HubSpot deal ID -export type ApprovalsByDeal = Record; - // ----------------------------------------------------------------------- // Top-level props for LiveTracker (client root) // ----------------------------------------------------------------------- @@ -177,9 +169,6 @@ export type LiveTrackerProps = { totalDeals: number; majorConditionDeals: ClassifiedDeal[]; // for Awaab's Law card docStatusMap: DocStatusMap; - userCapability: PortfolioCapabilityType; - approvalsByDeal: ApprovalsByDeal; - portfolioId: string; }; // -----------------------------------------------------------------------