mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-08 11:37:25 +00:00
revert back to main
This commit is contained in:
parent
ab4fdf3000
commit
498909ffeb
39 changed files with 125 additions and 4214 deletions
|
|
@ -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 |
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<bigint | null> {
|
||||
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<boolean> {
|
||||
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<string, string[]> = {};
|
||||
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<typeof bodySchema>;
|
||||
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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<string, string> = {
|
||||
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<string, string>
|
||||
): { 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<Record<string, unknown>>(sheet, { defval: "" });
|
||||
|
||||
if (rows.length === 0) return { error: "Empty file" };
|
||||
|
||||
const sourceHeaders = Object.keys(rows[0]);
|
||||
const outputHeaders: string[] = [];
|
||||
const sourceToOutput: Record<string, string> = {};
|
||||
|
||||
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<string, unknown> = {};
|
||||
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 });
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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<typeof bodySchema>;
|
||||
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<typeof bodySchema>;
|
||||
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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<number>`count(case when lower(${subTasks.status}) in ('completed', 'complete') then 1 end)::int`,
|
||||
failedSubtasks: sql<number>`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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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<typeof bodySchema>;
|
||||
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<typeof bodySchema>;
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -37,112 +37,112 @@ export default function AddNew({
|
|||
|
||||
return (
|
||||
<>
|
||||
<BulkUploadComingSoonModal
|
||||
isOpen={isBulkUploadOpen}
|
||||
onClose={() => setIsBulkUploadOpen(false)}
|
||||
portfolioId={portfolioId}
|
||||
/>
|
||||
<Menu as="div" className="relative inline-block text-left">
|
||||
<MenuButton
|
||||
className="
|
||||
<BulkUploadComingSoonModal
|
||||
isOpen={isBulkUploadOpen}
|
||||
onClose={() => setIsBulkUploadOpen(false)}
|
||||
portfolioId={portfolioId}
|
||||
/>
|
||||
<Menu as="div" className="relative inline-block text-left">
|
||||
<MenuButton
|
||||
className="
|
||||
inline-flex items-center gap-1 px-4 py-2 rounded-md
|
||||
bg-gray-50 text-gray-900 hover:bg-midblue hover:text-gray-100
|
||||
transition-colors text-sm font-medium
|
||||
"
|
||||
>
|
||||
<DocumentPlusIcon className="h-4 w-4 mr-2" />
|
||||
New Property
|
||||
<ChevronDownIcon className="h-4 w-4 opacity-70" />
|
||||
</MenuButton>
|
||||
>
|
||||
<DocumentPlusIcon className="h-4 w-4 mr-2" />
|
||||
New Property
|
||||
<ChevronDownIcon className="h-4 w-4 opacity-70" />
|
||||
</MenuButton>
|
||||
|
||||
<MenuItems
|
||||
className="
|
||||
<MenuItems
|
||||
className="
|
||||
absolute right-0 mt-3 w-72 origin-top-right rounded-md
|
||||
bg-white shadow-lg ring-1 ring-black/5 focus:outline-none
|
||||
z-[9999] py-3
|
||||
"
|
||||
>
|
||||
<div className="flex flex-col gap-2 px-3">
|
||||
{/* Remote Assessment */}
|
||||
<MenuItem>
|
||||
{({ active }) => (
|
||||
<button
|
||||
onClick={handleRemoteAssessment}
|
||||
className={cn(
|
||||
"w-full p-3 rounded-lg text-left flex gap-3 transition-colors",
|
||||
active && "bg-gray-100",
|
||||
)}
|
||||
>
|
||||
<DocumentMagnifyingGlassIcon className="h-5 w-5 text-gray-700 mt-[2px]" />
|
||||
>
|
||||
<div className="flex flex-col gap-2 px-3">
|
||||
{/* Remote Assessment */}
|
||||
<MenuItem>
|
||||
{({ active }) => (
|
||||
<button
|
||||
onClick={handleRemoteAssessment}
|
||||
className={cn(
|
||||
"w-full p-3 rounded-lg text-left flex gap-3 transition-colors",
|
||||
active && "bg-gray-100"
|
||||
)}
|
||||
>
|
||||
<DocumentMagnifyingGlassIcon className="h-5 w-5 text-gray-700 mt-[2px]" />
|
||||
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium text-gray-900 flex items-center gap-2">
|
||||
Remote Assessment
|
||||
{loadingRemote && (
|
||||
<span className="h-3 w-3 rounded-full border-2 border-gray-400 border-t-transparent animate-spin"></span>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 leading-snug">
|
||||
Run a remote assessment for a single property.
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</MenuItem>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium text-gray-900 flex items-center gap-2">
|
||||
Remote Assessment
|
||||
{loadingRemote && (
|
||||
<span className="h-3 w-3 rounded-full border-2 border-gray-400 border-t-transparent animate-spin"></span>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 leading-snug">
|
||||
Run a remote assessment for a single property.
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</MenuItem>
|
||||
|
||||
{/* CSV Upload */}
|
||||
<MenuItem>
|
||||
{({ active }) => (
|
||||
<button
|
||||
onClick={() => setIsUploadCsvOpen(!isUploadCsvOpen)}
|
||||
className={cn(
|
||||
"w-full p-3 rounded-lg text-left flex gap-3 transition-colors",
|
||||
active && "bg-gray-100",
|
||||
)}
|
||||
>
|
||||
<TableCellsIcon className="h-5 w-5 text-gray-700 mt-[2px]" />
|
||||
{/* CSV Upload */}
|
||||
<MenuItem>
|
||||
{({ active }) => (
|
||||
<button
|
||||
onClick={() => setIsUploadCsvOpen(!isUploadCsvOpen)}
|
||||
className={cn(
|
||||
"w-full p-3 rounded-lg text-left flex gap-3 transition-colors",
|
||||
active && "bg-gray-100"
|
||||
)}
|
||||
>
|
||||
<TableCellsIcon className="h-5 w-5 text-gray-700 mt-[2px]" />
|
||||
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
File Import
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 leading-snug">
|
||||
For bulk uploads, please contact a Domna user.
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</MenuItem>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
File Import
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 leading-snug">
|
||||
For bulk uploads, please contact a Domna user.
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</MenuItem>
|
||||
|
||||
{/* Bulk Upload (Coming Soon) */}
|
||||
<MenuItem>
|
||||
{({ active }) => (
|
||||
<button
|
||||
onClick={() => setIsBulkUploadOpen(true)}
|
||||
className={cn(
|
||||
"w-full p-3 rounded-lg text-left flex gap-3 transition-colors",
|
||||
active && "bg-gray-100",
|
||||
)}
|
||||
>
|
||||
<RectangleStackIcon className="h-5 w-5 text-gray-700 mt-[2px]" />
|
||||
{/* Bulk Upload (Coming Soon) */}
|
||||
<MenuItem>
|
||||
{({ active }) => (
|
||||
<button
|
||||
onClick={() => setIsBulkUploadOpen(true)}
|
||||
className={cn(
|
||||
"w-full p-3 rounded-lg text-left flex gap-3 transition-colors",
|
||||
active && "bg-gray-100"
|
||||
)}
|
||||
>
|
||||
<RectangleStackIcon className="h-5 w-5 text-gray-700 mt-[2px]" />
|
||||
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium text-gray-900 flex items-center gap-2">
|
||||
new: Bulk upload
|
||||
<span className="text-[10px] font-semibold text-amber-700 bg-amber-100 px-1.5 py-0.5 rounded-full leading-none">
|
||||
coming soon
|
||||
</span>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium text-gray-900 flex items-center gap-2">
|
||||
new: Bulk upload
|
||||
<span className="text-[10px] font-semibold text-amber-700 bg-amber-100 px-1.5 py-0.5 rounded-full leading-none">
|
||||
coming soon
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 leading-snug">
|
||||
Upload multiple addresses in one go.
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</MenuItem>
|
||||
</div>
|
||||
</MenuItems>
|
||||
</Menu>
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 leading-snug">
|
||||
Upload multiple addresses in one go.
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</MenuItem>
|
||||
</div>
|
||||
</MenuItems>
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, string> = {
|
||||
".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<string[]>(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<string> {
|
||||
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<HTMLInputElement>(null);
|
||||
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [sourceHeaders, setSourceHeaders] = useState<string[]>([]);
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
const [validating, setValidating] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState<number | null>(null);
|
||||
const [uploadError, setUploadError] = useState<string | null>(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<HTMLDivElement>) {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
}
|
||||
|
||||
function handleDragLeave() {
|
||||
setIsDragging(false);
|
||||
}
|
||||
|
||||
function handleDrop(e: DragEvent<HTMLDivElement>) {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file) handleFile(file);
|
||||
}
|
||||
|
||||
function handleInputChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
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<void>((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 (
|
||||
<Transition appear show={isOpen} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-[9999]" onClose={handleClose}>
|
||||
{/* Backdrop */}
|
||||
<TransitionChild
|
||||
as={Fragment}
|
||||
enter="ease-out duration-200"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-150"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<DialogBackdrop className="fixed inset-0 bg-gray-900/40 backdrop-blur-sm" />
|
||||
</TransitionChild>
|
||||
|
||||
{/* Panel */}
|
||||
<div className="fixed inset-0 flex items-center justify-center p-4">
|
||||
<TransitionChild
|
||||
as={Fragment}
|
||||
enter="ease-out duration-200"
|
||||
enterFrom="opacity-0 scale-95 translate-y-2"
|
||||
enterTo="opacity-100 scale-100 translate-y-0"
|
||||
leave="ease-in duration-150"
|
||||
leaveFrom="opacity-100 scale-100 translate-y-0"
|
||||
leaveTo="opacity-0 scale-95 translate-y-2"
|
||||
>
|
||||
<DialogPanel className="w-full max-w-2xl bg-white rounded-2xl shadow-[0_40px_60px_-15px_rgba(21,29,33,0.15)] overflow-hidden flex flex-col">
|
||||
|
||||
{/* Header */}
|
||||
<div className="px-10 pt-10 pb-6 flex justify-between items-start">
|
||||
<div>
|
||||
<DialogTitle className="text-2xl font-extrabold text-gray-900 tracking-tight mb-2">
|
||||
Bulk Upload: New Properties
|
||||
</DialogTitle>
|
||||
<p className="text-sm text-gray-500 leading-relaxed max-w-md">
|
||||
This workflow is designed for adding new residential or commercial
|
||||
assets to your portfolio. Upload your dataset to begin the
|
||||
transformation.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="p-2 hover:bg-gray-100 rounded-full transition-colors ml-4 shrink-0"
|
||||
>
|
||||
<XMarkIcon className="h-5 w-5 text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-10 py-2 space-y-5">
|
||||
|
||||
{/* Template section */}
|
||||
<div className="bg-gray-50 p-5 rounded-xl flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-11 h-11 rounded-lg bg-gray-100 flex items-center justify-center shrink-0">
|
||||
<DocumentTextIcon className="h-6 w-6 text-midblue" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-gray-900">Required Template Format</p>
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
Must contain:{" "}
|
||||
<span className="font-medium text-midblue">
|
||||
Address, Postcode
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={downloadTemplate}
|
||||
className="flex items-center gap-1.5 text-xs font-semibold text-midblue hover:text-gray-900 transition-colors shrink-0 ml-4"
|
||||
>
|
||||
<ArrowDownTrayIcon className="h-4 w-4" />
|
||||
Download template
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Dropzone */}
|
||||
<div
|
||||
onClick={() => !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"
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".csv,.xlsx,.xls"
|
||||
className="hidden"
|
||||
onChange={handleInputChange}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
|
||||
{validating ? (
|
||||
<>
|
||||
<div className="w-14 h-14 rounded-full bg-gray-100 flex items-center justify-center mb-4">
|
||||
<span className="h-6 w-6 rounded-full border-2 border-midblue border-t-transparent animate-spin" />
|
||||
</div>
|
||||
<p className="text-base font-bold text-gray-900 mb-1">Checking headers…</p>
|
||||
<p className="text-xs text-gray-400">Validating column structure</p>
|
||||
</>
|
||||
) : uploading ? (
|
||||
<>
|
||||
<div className="w-14 h-14 rounded-full bg-gray-100 flex items-center justify-center mb-4">
|
||||
<CloudArrowUpIcon className="h-7 w-7 text-midblue" />
|
||||
</div>
|
||||
<p className="text-base font-bold text-gray-900 mb-1">Uploading…</p>
|
||||
<p className="text-xs text-gray-400 mb-4">{selectedFile?.name}</p>
|
||||
<div className="w-full max-w-xs bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-midblue h-2 rounded-full transition-all duration-200"
|
||||
style={{ width: `${uploadProgress ?? 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mt-1">{uploadProgress ?? 0}%</p>
|
||||
</>
|
||||
) : validationError ? (
|
||||
<>
|
||||
<ExclamationCircleIcon className="h-14 w-14 text-red-400 mb-4" />
|
||||
<p className="text-base font-bold text-red-600 mb-1">{validationError}</p>
|
||||
<p className="text-xs text-gray-400">Click to choose a different file</p>
|
||||
</>
|
||||
) : selectedFile ? (
|
||||
<>
|
||||
<CheckCircleIcon className="h-14 w-14 text-green-400 mb-4" />
|
||||
<p className="text-base font-bold text-gray-900 mb-1">{selectedFile.name}</p>
|
||||
<p className="text-xs text-gray-400">Click to change file</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="w-14 h-14 rounded-full bg-gray-100 flex items-center justify-center mb-4">
|
||||
<CloudArrowUpIcon className="h-7 w-7 text-midblue" />
|
||||
</div>
|
||||
<p className="text-base font-bold text-gray-900 mb-1">
|
||||
Drag and drop CSV or XLSX
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
or <span className="text-midblue font-semibold">click to browse</span> · Max {MAX_FILE_SIZE_MB}MB
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Upload error */}
|
||||
{uploadError && (
|
||||
<p className="text-xs text-red-500 flex items-center gap-1.5">
|
||||
<ExclamationCircleIcon className="h-4 w-4 shrink-0" />
|
||||
{uploadError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Info strip */}
|
||||
<div className="flex items-center gap-3 text-xs text-gray-500 bg-gray-50 px-4 py-3 rounded-lg">
|
||||
<InformationCircleIcon className="h-4 w-4 text-midblue shrink-0" />
|
||||
<span>
|
||||
Properties will be automatically validated against national
|
||||
architectural databases.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-10 py-7 mt-4 flex items-center justify-between bg-gray-50/50">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="text-sm font-semibold text-gray-400 hover:text-gray-700 transition-colors"
|
||||
>
|
||||
Cancel and Exit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { handleClose(); router.push(`/portfolio/${portfolioId}/bulk-upload`); }}
|
||||
className="text-sm font-semibold text-midblue hover:text-gray-900 transition-colors"
|
||||
>
|
||||
View previous uploads
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={handleUpload}
|
||||
disabled={!canUpload}
|
||||
className={`flex items-center gap-2 px-7 py-2.5 rounded-2xl bg-gradient-to-br from-[#14163d] to-[#15173e] text-white text-sm font-bold transition-opacity ${
|
||||
canUpload ? "opacity-100 hover:opacity-90" : "opacity-40 cursor-not-allowed"
|
||||
}`}
|
||||
>
|
||||
{uploading ? (
|
||||
<>
|
||||
<span className="h-4 w-4 rounded-full border-2 border-white border-t-transparent animate-spin" />
|
||||
Uploading…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Upload File
|
||||
<ArrowRightIcon className="h-4 w-4" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</DialogPanel>
|
||||
</TransitionChild>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
);
|
||||
}
|
||||
|
|
@ -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"
|
||||
>;
|
||||
|
|
@ -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<Record<string, string>>(),
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -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<typeof portfolio, "select">;
|
||||
export type NewPortfolio = InferModel<typeof portfolio, "insert">;
|
||||
export type PortfolioUsers = InferModel<typeof portfolioUsers, "select">;
|
||||
export type NewPortfolioUsers = InferModel<typeof portfolioUsers, "insert">;
|
||||
export type PortfolioCapabilities = InferModel<
|
||||
typeof portfolioCapabilities,
|
||||
"select"
|
||||
>;
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
);
|
||||
|
|
@ -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<TaskData | null>(null);
|
||||
const [fetchError, setFetchError] = useState(false);
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | 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 (
|
||||
<div className="mt-4 flex items-center gap-2 text-sm text-gray-400">
|
||||
<span className="w-4 h-4 rounded-full border-2 border-gray-300 border-t-transparent animate-spin" />
|
||||
Loading progress…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="mt-6 space-y-3">
|
||||
{/* Progress bar */}
|
||||
<div className="w-full bg-gray-100 rounded-full h-2 overflow-hidden">
|
||||
<div
|
||||
className={`h-2 rounded-full transition-all duration-500 ${isFailed ? "bg-red-400" : "bg-[#14163d]"}`}
|
||||
style={{ width: total > 0 ? `${percent}%` : "4%" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Counts */}
|
||||
<div className="flex items-center gap-4 text-xs text-gray-500">
|
||||
{total > 0 && (
|
||||
<span>
|
||||
<span className="font-semibold text-gray-700">{complete}</span> / {total} batches complete
|
||||
</span>
|
||||
)}
|
||||
{failed > 0 && (
|
||||
<span className="flex items-center gap-1 text-red-500 font-semibold">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-red-400" />
|
||||
{failed} failed
|
||||
</span>
|
||||
)}
|
||||
{!isDone && (
|
||||
<span className="flex items-center gap-1 text-blue-500">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-blue-400 animate-pulse" />
|
||||
Running
|
||||
</span>
|
||||
)}
|
||||
{isDone && !isFailed && (
|
||||
<span className="flex items-center gap-1 text-green-600 font-semibold">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-green-500" />
|
||||
Complete
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isDomnaUser && (
|
||||
<Link
|
||||
href={`/portfolio/${portfolioSlug}/settings/logs`}
|
||||
className="text-xs text-gray-400 hover:text-gray-700 underline underline-offset-2 transition-colors"
|
||||
>
|
||||
View detailed logs
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<string | null>(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 (
|
||||
<div className="mt-4">
|
||||
<button
|
||||
onClick={handleStart}
|
||||
disabled={loading}
|
||||
className={`inline-flex items-center gap-2 px-5 py-2 rounded-xl bg-gradient-to-br from-[#14163d] to-[#15173e] text-white text-sm font-bold transition-opacity ${
|
||||
loading ? "opacity-50 cursor-not-allowed" : "hover:opacity-90"
|
||||
}`}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<span className="w-4 h-4 rounded-full border-2 border-white border-t-transparent animate-spin" />
|
||||
Starting…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Start Onboarding
|
||||
<ArrowRightIcon className="h-4 w-4" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{error && <p className="mt-2 text-xs text-red-500">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<string, string>
|
||||
): Record<string, string> {
|
||||
const mapping: Record<string, string> = {};
|
||||
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<string, string>;
|
||||
}
|
||||
|
||||
export default function MapColumnsClient({
|
||||
portfolioId,
|
||||
uploadId,
|
||||
filename,
|
||||
sourceHeaders,
|
||||
existingMapping,
|
||||
}: Props) {
|
||||
const router = useRouter();
|
||||
const [mapping, setMapping] = useState<Record<string, string>>(
|
||||
buildInitialMapping(sourceHeaders, existingMapping)
|
||||
);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="max-w-4xl mx-auto px-6 py-10">
|
||||
{/* Breadcrumb + step */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="text-xs font-semibold text-gray-400 uppercase tracking-widest">
|
||||
Bulk Uploads › Column Remapper
|
||||
</p>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs font-semibold text-gray-400 uppercase tracking-wider">
|
||||
Step 2 of 3
|
||||
</span>
|
||||
<div className="flex gap-1 ml-2">
|
||||
{[1, 2, 3].map((s) => (
|
||||
<div
|
||||
key={s}
|
||||
className={`h-1.5 rounded-full ${
|
||||
s <= 2 ? "w-8 bg-[#14163d]" : "w-8 bg-gray-200"
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-extrabold text-gray-900 tracking-tight mb-1">
|
||||
Column Remapper
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500 max-w-lg">
|
||||
Align your spreadsheet headers with our internal property data structure to
|
||||
ensure accurate address processing.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="bg-white border border-gray-100 rounded-2xl overflow-hidden shadow-sm mb-6">
|
||||
{/* Column headers */}
|
||||
<div className="grid grid-cols-12 items-center px-6 py-3 bg-gray-50 border-b border-gray-100">
|
||||
<span className="col-span-4 text-xs font-semibold text-gray-400 uppercase tracking-wider">
|
||||
Spreadsheet Header
|
||||
</span>
|
||||
<span className="col-span-1" />
|
||||
<span className="col-span-5 text-xs font-semibold text-gray-400 uppercase tracking-wider">
|
||||
Internal Field Mapping
|
||||
</span>
|
||||
<span className="col-span-2 text-xs font-semibold text-gray-400 uppercase tracking-wider text-right">
|
||||
Status
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{sourceHeaders.length === 0 ? (
|
||||
<div className="px-6 py-12 text-center text-sm text-gray-400">
|
||||
No headers found in this file.
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-50">
|
||||
{sourceHeaders.map((header) => {
|
||||
const value = mapping[header] ?? "skip";
|
||||
const isMapped = value !== "skip";
|
||||
return (
|
||||
<div
|
||||
key={header}
|
||||
className="grid grid-cols-12 items-center px-6 py-4 hover:bg-gray-50/50 transition-colors"
|
||||
>
|
||||
{/* Source header */}
|
||||
<div className="col-span-4 flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-gray-100 flex items-center justify-center shrink-0">
|
||||
<TableCellsIcon className="h-4 w-4 text-gray-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-gray-900">{header}</p>
|
||||
<p className="text-xs text-gray-400">Source column</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Arrow */}
|
||||
<div className="col-span-1 flex justify-center">
|
||||
<ArrowsRightLeftIcon className="h-4 w-4 text-gray-300" />
|
||||
</div>
|
||||
|
||||
{/* Dropdown */}
|
||||
<div className="col-span-5">
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => setField(header, e.target.value)}
|
||||
className="w-full text-sm border border-gray-200 rounded-lg px-3 py-2 bg-white text-gray-800 focus:outline-none focus:ring-2 focus:ring-[#14163d]/20 focus:border-[#14163d]"
|
||||
>
|
||||
{INTERNAL_FIELDS.map((f) => (
|
||||
<option key={f.value} value={f.value}>
|
||||
{f.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Status badge */}
|
||||
<div className="col-span-2 flex justify-end">
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-semibold ${
|
||||
isMapped
|
||||
? "bg-amber-50 text-amber-700"
|
||||
: "bg-gray-100 text-gray-400"
|
||||
}`}
|
||||
>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-current opacity-70" />
|
||||
{isMapped ? "Mapped" : "Skipped"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Validation error */}
|
||||
{missingRequired.length > 0 && (
|
||||
<p className="text-xs text-amber-600 mb-4">
|
||||
Required fields not yet mapped:{" "}
|
||||
{missingRequired
|
||||
.map((r) => INTERNAL_FIELDS.find((f) => f.value === r)?.label)
|
||||
.join(", ")}
|
||||
</p>
|
||||
)}
|
||||
{error && <p className="text-xs text-red-500 mb-4">{error}</p>}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Link
|
||||
href={`/portfolio/${portfolioId}/bulk-upload`}
|
||||
className="flex items-center gap-1.5 text-sm font-semibold text-gray-400 hover:text-gray-700 transition-colors"
|
||||
>
|
||||
<ArrowLeftIcon className="h-4 w-4" />
|
||||
Back
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
href={`/portfolio/${portfolioId}/bulk-upload`}
|
||||
className="text-sm font-semibold text-gray-400 hover:text-gray-700 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSubmit}
|
||||
className={`flex items-center gap-2 px-7 py-2.5 rounded-2xl bg-gradient-to-br from-[#14163d] to-[#15173e] text-white text-sm font-bold transition-opacity ${
|
||||
canSubmit ? "opacity-100 hover:opacity-90" : "opacity-40 cursor-not-allowed"
|
||||
}`}
|
||||
>
|
||||
{submitting ? "Saving…" : "Process Mapping"}
|
||||
{!submitting && <ArrowRightIcon className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pro tip */}
|
||||
<div className="mt-10 bg-gray-50 rounded-2xl p-6">
|
||||
<p className="text-xs font-semibold text-midblue uppercase tracking-wider mb-2">
|
||||
Pro Tip
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 italic">
|
||||
“Ensure your source file doesn't have blank headers. Any column mapped to
|
||||
“Skip” will be ignored during import.”
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<MapColumnsClient
|
||||
portfolioId={slug}
|
||||
uploadId={uploadId}
|
||||
filename={upload.filename}
|
||||
sourceHeaders={upload.sourceHeaders}
|
||||
existingMapping={upload.columnMapping ?? undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div className="max-w-2xl mx-auto px-6 py-10">
|
||||
{/* Back */}
|
||||
<Link
|
||||
href={`/portfolio/${slug}/bulk-upload`}
|
||||
className="inline-flex items-center gap-1.5 text-sm text-gray-400 hover:text-gray-700 transition-colors mb-8"
|
||||
>
|
||||
<ArrowLeftIcon className="h-4 w-4" />
|
||||
Back to uploads
|
||||
</Link>
|
||||
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<p className="text-xs font-semibold text-gray-400 uppercase tracking-widest mb-1">
|
||||
Bulk Upload
|
||||
</p>
|
||||
<h1 className="text-3xl font-extrabold text-gray-900 tracking-tight mb-1">
|
||||
{upload.filename}
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500">Uploaded {formatDate(upload.createdAt)}</p>
|
||||
</div>
|
||||
|
||||
{/* Status card */}
|
||||
<div className="bg-white border border-gray-100 rounded-2xl p-6 shadow-sm mb-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className={`w-11 h-11 rounded-xl flex items-center justify-center shrink-0 ${config.iconBg}`}>
|
||||
<Icon className={`h-6 w-6 ${config.iconColor}`} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-semibold text-gray-900 mb-1">{config.title}</p>
|
||||
<p className="text-sm text-gray-500 leading-relaxed">{config.body}</p>
|
||||
|
||||
{statusKey === "ready_for_processing" && (
|
||||
<Link
|
||||
href={`/portfolio/${slug}/bulk-upload/${uploadId}/map-columns`}
|
||||
className="mt-4 inline-flex items-center gap-2 px-5 py-2 rounded-xl bg-gradient-to-br from-[#14163d] to-[#15173e] text-white text-sm font-bold hover:opacity-90 transition-opacity"
|
||||
>
|
||||
Map Columns
|
||||
<ArrowRightIcon className="h-4 w-4" />
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{statusKey === "mapping_complete" && (
|
||||
<div className="mt-4 flex flex-col gap-3">
|
||||
<Link
|
||||
href={`/portfolio/${slug}/bulk-upload/${uploadId}/map-columns`}
|
||||
className="inline-flex items-center gap-1.5 text-sm text-gray-400 hover:text-gray-700 transition-colors"
|
||||
>
|
||||
Edit column mapping
|
||||
<ArrowRightIcon className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
<StartOnboardingButton
|
||||
portfolioId={upload.portfolioId}
|
||||
uploadId={uploadId}
|
||||
filename={upload.filename}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(statusKey === "processing" || statusKey === "complete" || statusKey === "failed") &&
|
||||
upload.taskId && (
|
||||
<OnboardingProgress taskId={upload.taskId} portfolioSlug={slug} isDomnaUser={isDomnaUser} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<string, { label: string; classes: string }> = {
|
||||
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 (
|
||||
<div className="max-w-4xl mx-auto px-6 py-10">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<p className="text-xs font-semibold text-gray-400 uppercase tracking-widest mb-1">
|
||||
Portfolio › Bulk Uploads
|
||||
</p>
|
||||
<h1 className="text-3xl font-extrabold text-gray-900 tracking-tight">
|
||||
Batch Uploads
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Select an upload to continue processing, or start a new import.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{uploads.length === 0 ? (
|
||||
/* Empty state */
|
||||
<div className="flex flex-col items-center justify-center py-24 border-2 border-dashed border-gray-200 rounded-2xl text-center">
|
||||
<div className="w-14 h-14 rounded-full bg-gray-100 flex items-center justify-center mb-4">
|
||||
<CloudArrowUpIcon className="h-7 w-7 text-gray-400" />
|
||||
</div>
|
||||
<p className="text-base font-semibold text-gray-700 mb-1">No uploads yet</p>
|
||||
<p className="text-sm text-gray-400">
|
||||
Use the Bulk Upload button on your portfolio to get started.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
/* Upload list */
|
||||
<div className="space-y-3">
|
||||
{/* Column headers */}
|
||||
<div className="grid grid-cols-12 px-4 pb-1">
|
||||
<span className="col-span-6 text-xs font-semibold text-gray-400 uppercase tracking-wider">
|
||||
File
|
||||
</span>
|
||||
<span className="col-span-3 text-xs font-semibold text-gray-400 uppercase tracking-wider">
|
||||
Uploaded
|
||||
</span>
|
||||
<span className="col-span-2 text-xs font-semibold text-gray-400 uppercase tracking-wider">
|
||||
Status
|
||||
</span>
|
||||
<span className="col-span-1" />
|
||||
</div>
|
||||
|
||||
{uploads.map((upload) => {
|
||||
const status = STATUS_LABELS[upload.status] ?? {
|
||||
label: upload.status,
|
||||
classes: "bg-gray-100 text-gray-600",
|
||||
};
|
||||
return (
|
||||
<Link
|
||||
key={upload.id}
|
||||
href={`/portfolio/${slug}/bulk-upload/${upload.id}`}
|
||||
className="grid grid-cols-12 items-center bg-white border border-gray-100 rounded-xl px-4 py-4 hover:border-gray-300 hover:shadow-sm transition-all group"
|
||||
>
|
||||
{/* Filename */}
|
||||
<div className="col-span-6 flex items-center gap-3 min-w-0">
|
||||
<div className="w-9 h-9 rounded-lg bg-gray-50 border border-gray-100 flex items-center justify-center shrink-0">
|
||||
<DocumentTextIcon className="h-5 w-5 text-midblue" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-gray-900 truncate">
|
||||
{upload.filename}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 truncate font-mono">
|
||||
{upload.s3Key.split("/").pop()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Date */}
|
||||
<div className="col-span-3">
|
||||
<p className="text-sm text-gray-500">
|
||||
{formatDate(upload.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Status badge */}
|
||||
<div className="col-span-2">
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-semibold ${status.classes}`}
|
||||
>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-current opacity-70" />
|
||||
{status.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Arrow */}
|
||||
<div className="col-span-1 flex justify-end">
|
||||
<ArrowRightIcon className="h-4 w-4 text-gray-300 group-hover:text-gray-600 transition-colors" />
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<string, { name: string | null; email: string; capabilities: Capability[] }>;
|
||||
|
||||
async function getCapabilities(portfolioId: string): Promise<CapabilityEntry[]> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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 (
|
||||
<div className="rounded-md border border-gray-700 mt-4">
|
||||
<Table>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableHead className="text-brandblue">
|
||||
Project Roles:
|
||||
<p className="text-xs text-gray-500">
|
||||
Assign approver or contractor capabilities to users
|
||||
</p>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell colSpan={3}>
|
||||
<div className="rounded-md border border-gray-200">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead>Approver</TableHead>
|
||||
<TableHead>Contractor</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-sm text-gray-500">
|
||||
Loading…
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : rows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-sm text-gray-500">
|
||||
No collaborators yet. Add users in the section above first.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
rows.map(([userId, { name, email, capabilities }]) => (
|
||||
<TableRow key={userId}>
|
||||
<TableCell>{name || "—"}</TableCell>
|
||||
<TableCell className="text-sm text-gray-600">{email}</TableCell>
|
||||
<TableCell>
|
||||
<CapabilityToggle
|
||||
has={capabilities.includes("approver")}
|
||||
capability="approver"
|
||||
isPending={toggleMutation.isPending}
|
||||
onToggle={(has) =>
|
||||
toggleMutation.mutate({ userId, capability: "approver", has })
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<CapabilityToggle
|
||||
has={capabilities.includes("contractor")}
|
||||
capability="contractor"
|
||||
isPending={toggleMutation.isPending}
|
||||
onToggle={(has) =>
|
||||
toggleMutation.mutate({ userId, capability: "contractor", has })
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CapabilityToggle({
|
||||
has,
|
||||
capability,
|
||||
isPending,
|
||||
onToggle,
|
||||
}: {
|
||||
has: boolean;
|
||||
capability: Capability;
|
||||
isPending: boolean;
|
||||
onToggle: (has: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
variant={has ? "default" : "outline"}
|
||||
disabled={isPending}
|
||||
onClick={() => onToggle(has)}
|
||||
className={has ? "bg-brandblue text-white" : ""}
|
||||
>
|
||||
{has ? (
|
||||
<Badge className="bg-transparent text-white p-0 shadow-none">
|
||||
{capability === "approver" ? "Approver" : "Contractor"} ✓
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-gray-500">
|
||||
Add {capability === "approver" ? "Approver" : "Contractor"}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
|
@ -27,13 +27,15 @@ async function getPortfolioUsers(portfolioId: string): Promise<Collaborator[]> {
|
|||
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 }) {
|
|||
<TableCell>{c.name || "—"}</TableCell>
|
||||
<TableCell>{c.email}</TableCell>
|
||||
<TableCell className="min-w-40">
|
||||
{c.role === "creator" || c.role === "admin" ? (
|
||||
<span className="text-xs font-medium text-gray-500 px-2 py-1 bg-gray-100 rounded-md capitalize">
|
||||
{c.role}
|
||||
</span>
|
||||
) : (
|
||||
<RoleDropdown value={c.role as "read" | "write"} onChange={(r) => onChangeRole(c.portfolioUserId, r)} />
|
||||
)}
|
||||
<RoleDropdown value={c.role} onChange={(r) => onChangeRole(c.portfolioUserId, r)} />
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{c.role !== "creator" && (
|
||||
<Button variant="destructive" className="bg-red-700" onClick={() => onRemove(c.portfolioUserId)}>
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="destructive" className="bg-red-700" onClick={() => onRemove(c.portfolioUserId)}>
|
||||
Remove
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div>
|
||||
<UsersPermissionsCard portfolioId={slug} />
|
||||
<CapabilitiesCard portfolioId={slug} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, PendingDiff>; // dealId -> diff
|
||||
dealNames: Record<string, string>; // 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 (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-brandblue">Confirm approval changes</DialogTitle>
|
||||
<DialogDescription>
|
||||
Review the changes below. This action will be recorded in the audit
|
||||
log and cannot be undone automatically.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 max-h-80 overflow-y-auto py-1 pr-1">
|
||||
{Object.entries(pendingDiffs).map(([dealId, diff]) => {
|
||||
if (diff.added.length === 0 && diff.removed.length === 0) return null;
|
||||
const name = dealNames[dealId] ?? dealId;
|
||||
return (
|
||||
<div key={dealId} className="space-y-2">
|
||||
<p className="text-sm font-semibold text-gray-700">{name}</p>
|
||||
<div className="space-y-1 pl-2">
|
||||
{diff.added.map((m) => (
|
||||
<div key={`add-${m}`} className="flex items-center gap-2">
|
||||
<CheckCircle2 className="h-4 w-4 text-emerald-500 shrink-0" />
|
||||
<span className="text-sm text-emerald-700">{m}</span>
|
||||
<span className="text-xs text-gray-400">will be approved</span>
|
||||
</div>
|
||||
))}
|
||||
{diff.removed.map((m) => (
|
||||
<div key={`rem-${m}`} className="flex items-center gap-2">
|
||||
<XCircle className="h-4 w-4 text-red-400 shrink-0" />
|
||||
<span className="text-sm text-red-600">{m}</span>
|
||||
<span className="text-xs text-gray-400">will be unapproved</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 pt-2 border-t border-gray-100">
|
||||
<p className="text-sm text-gray-600">
|
||||
To confirm{" "}
|
||||
<span className="font-semibold">
|
||||
{totalAdded > 0 && `${totalAdded} approval${totalAdded > 1 ? "s" : ""}`}
|
||||
{totalAdded > 0 && totalRemoved > 0 && " and "}
|
||||
{totalRemoved > 0 && `${totalRemoved} removal${totalRemoved > 1 ? "s" : ""}`}
|
||||
</span>
|
||||
, type{" "}
|
||||
<code className="px-1 py-0.5 bg-gray-100 rounded text-brandblue font-mono text-xs">
|
||||
{CONFIRM_WORD}
|
||||
</code>{" "}
|
||||
below:
|
||||
</p>
|
||||
<Input
|
||||
value={typed}
|
||||
onChange={(e) => setTyped(e.target.value)}
|
||||
placeholder={`Type "${CONFIRM_WORD}" to confirm`}
|
||||
className="font-mono"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="secondary" onClick={onCancel} disabled={isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setTyped("");
|
||||
onConfirm();
|
||||
}}
|
||||
disabled={!canConfirm}
|
||||
className="bg-brandblue text-white"
|
||||
>
|
||||
{isPending ? "Saving…" : "Confirm"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<string> {
|
||||
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<string> {
|
||||
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<void> {
|
||||
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 (
|
||||
<Select value={value} onValueChange={onChange}>
|
||||
<SelectTrigger className="h-8 text-xs w-full">
|
||||
<SelectValue placeholder="Select type…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FILE_TYPE_GROUPS.map((group) => {
|
||||
const items = FILE_TYPE_OPTIONS.filter((o) => o.group === group);
|
||||
if (!items.length) return null;
|
||||
return (
|
||||
<SelectGroup key={group}>
|
||||
<SelectLabel className="text-[10px] text-gray-400 uppercase tracking-wide">{group}</SelectLabel>
|
||||
{items.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value} className="text-xs">{o.label}</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Status icon ────────────────────────────────────────────────────────────
|
||||
|
||||
function StatusIcon({ status, isExisting, errorMsg }: { status: FileStatus; isExisting?: boolean; errorMsg?: string }) {
|
||||
if (isExisting) return <Clock className="h-4 w-4 text-amber-400 shrink-0" aria-label="Pending classification" />;
|
||||
if (status === "queued") return <span className="h-4 w-4 rounded-full border-2 border-gray-200 shrink-0 inline-block" />;
|
||||
if (status === "uploading") return <Loader2 className="h-4 w-4 animate-spin text-brandblue shrink-0" />;
|
||||
if (status === "done") return <CheckCircle2 className="h-4 w-4 text-emerald-500 shrink-0" />;
|
||||
return <span title={errorMsg}><XCircle className="h-4 w-4 text-red-400 shrink-0" /></span>;
|
||||
}
|
||||
|
||||
// ── Main component ─────────────────────────────────────────────────────────
|
||||
|
||||
export default function ContractorUploadModal({ deal, portfolioId, onClose }: Props) {
|
||||
const measures = parseMeasures(deal.proposedMeasures);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [queue, setQueue] = useState<FileEntry[]>([]);
|
||||
const [phase, setPhase] = useState<Phase>("loading");
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(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<HTMLInputElement>) {
|
||||
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 (
|
||||
<Dialog open onOpenChange={onClose}>
|
||||
<DialogContent className="sm:max-w-xl max-h-[90vh] flex flex-col overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{phase === "loading" ? "Loading…" :
|
||||
phase === "upload" ? "Upload Documents" :
|
||||
"Classify Documents"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{phase === "loading" && "Checking for pending files…"}
|
||||
{phase === "upload" && (
|
||||
<>
|
||||
Upload install documents for <strong>{propertyLabel}</strong>.
|
||||
{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.
|
||||
</>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto min-h-0 space-y-4 py-2">
|
||||
|
||||
{/* ── Loading ── */}
|
||||
{phase === "loading" && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Phase 1: Upload ── */}
|
||||
{phase === "upload" && (
|
||||
<>
|
||||
{/* Existing unclassified banner */}
|
||||
{existingCount > 0 && (
|
||||
<div className="flex items-center gap-2 px-3 py-2.5 rounded-lg bg-amber-50 border border-amber-200 text-xs">
|
||||
<Clock className="h-4 w-4 text-amber-500 shrink-0" />
|
||||
<span className="text-amber-700">
|
||||
<strong>{existingCount}</strong> previously uploaded file{existingCount !== 1 ? "s" : ""} {existingCount !== 1 ? "are" : "is"} waiting to be classified.
|
||||
Add new files or go straight to classification.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Drop zone */}
|
||||
<div
|
||||
className={`border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-colors ${
|
||||
isDragOver ? "border-brandblue bg-brandlightblue/20" : "border-gray-200 hover:border-brandblue/40 hover:bg-gray-50"
|
||||
}`}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onDragOver={(e) => { e.preventDefault(); setIsDragOver(true); }}
|
||||
onDragLeave={() => setIsDragOver(false)}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<Upload className="h-6 w-6 text-gray-400 mx-auto mb-2" />
|
||||
<p className="text-sm font-medium text-gray-600">Drop files here or click to browse</p>
|
||||
<p className="text-xs text-gray-400 mt-1">PDF, JPG, PNG accepted · Multiple files OK</p>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept=".pdf,.jpg,.jpeg,.png"
|
||||
className="hidden"
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* New file queue */}
|
||||
{newQueuedCount > 0 && (
|
||||
<div className="space-y-1">
|
||||
{queue.filter((f) => f.file).map((entry) => (
|
||||
<div key={entry.id} className="flex items-center gap-3 px-3 py-2 rounded-lg bg-gray-50 border border-gray-100">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs font-medium text-gray-700 truncate">{entry.displayName}</p>
|
||||
{entry.displaySize && <p className="text-[10px] text-gray-400">{entry.displaySize}</p>}
|
||||
</div>
|
||||
<StatusIcon status={entry.status} />
|
||||
{entry.status === "queued" && (
|
||||
<button
|
||||
onClick={() => removeFile(entry.id)}
|
||||
className="text-gray-300 hover:text-gray-500 text-lg leading-none shrink-0"
|
||||
aria-label="Remove"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Phase 2: Classify ── */}
|
||||
{phase === "classify" && (
|
||||
<div className="space-y-3">
|
||||
{/* Column headers */}
|
||||
<div className="grid grid-cols-[1fr_180px_128px] gap-2 px-1">
|
||||
<span className="text-[10px] font-semibold text-gray-400 uppercase tracking-wide">File</span>
|
||||
<span className="text-[10px] font-semibold text-gray-400 uppercase tracking-wide">Document Type <span className="text-red-400">*</span></span>
|
||||
<span className="text-[10px] font-semibold text-gray-400 uppercase tracking-wide">Measure</span>
|
||||
</div>
|
||||
|
||||
{classifiableEntries.map((entry) => (
|
||||
<div key={entry.id} className="grid grid-cols-[1fr_180px_128px] gap-2 items-center px-1">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<StatusIcon status={entry.status} isExisting={!!entry.existingS3Key} />
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-medium text-gray-700 truncate">{entry.displayName}</p>
|
||||
{entry.displaySize && <p className="text-[10px] text-gray-400">{entry.displaySize}</p>}
|
||||
{entry.existingS3Key && <p className="text-[10px] text-amber-500">Previously uploaded</p>}
|
||||
</div>
|
||||
</div>
|
||||
<DocTypeSelect value={entry.docType} onChange={(v) => updateEntryField(entry.id, "docType", v)} />
|
||||
{measures.length > 0 ? (
|
||||
<Select value={entry.measureName} onValueChange={(v) => updateEntryField(entry.id, "measureName", v)}>
|
||||
<SelectTrigger className="h-8 text-xs w-full">
|
||||
<SelectValue placeholder="—" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="" className="text-xs text-gray-400">— None —</SelectItem>
|
||||
{measures.map((m) => (
|
||||
<SelectItem key={m} value={m} className="text-xs">{m}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<span className="text-xs text-gray-300">—</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Failed uploads (info only) */}
|
||||
{queue.filter((f) => f.status === "error").length > 0 && (
|
||||
<div className="p-3 rounded-lg bg-red-50 border border-red-200">
|
||||
<p className="text-xs font-medium text-red-700 mb-1">
|
||||
{queue.filter((f) => f.status === "error").length} file(s) failed and are excluded:
|
||||
</p>
|
||||
{queue.filter((f) => f.status === "error").map((f) => (
|
||||
<p key={f.id} className="text-xs text-red-600">{f.displayName}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{saveError && (
|
||||
<p className="text-sm text-red-600 bg-red-50 border border-red-200 rounded-md px-3 py-2">
|
||||
{saveError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="pt-2 border-t border-gray-100 shrink-0">
|
||||
{phase === "loading" && (
|
||||
<Button variant="secondary" onClick={onClose}>Cancel</Button>
|
||||
)}
|
||||
|
||||
{phase === "upload" && (
|
||||
<>
|
||||
<Button variant="secondary" onClick={onClose} disabled={isUploading}>Cancel</Button>
|
||||
<Button
|
||||
onClick={handleUpload}
|
||||
disabled={isUploading || (newQueuedCount === 0 && existingCount === 0)}
|
||||
className="bg-brandblue text-white gap-1.5"
|
||||
>
|
||||
{isUploading ? (
|
||||
<><Loader2 className="h-3.5 w-3.5 animate-spin" /> Uploading…</>
|
||||
) : newQueuedCount > 0 ? (
|
||||
<>Upload {newQueuedCount} file{newQueuedCount !== 1 ? "s" : ""} →</>
|
||||
) : (
|
||||
<>Classify {existingCount} pending file{existingCount !== 1 ? "s" : ""} →</>
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{phase === "classify" && (
|
||||
<>
|
||||
<Button variant="secondary" onClick={onClose} disabled={isSaving}>
|
||||
Skip for now
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSaveClassifications}
|
||||
disabled={!allClassified || isSaving}
|
||||
className="bg-brandblue text-white gap-1.5"
|
||||
>
|
||||
{isSaving ? (
|
||||
<><Loader2 className="h-3.5 w-3.5 animate-spin" /> Saving…</>
|
||||
) : (
|
||||
"Save Classifications →"
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<SurveyStatusFilter>("all");
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
|
|
@ -60,7 +57,6 @@ export default function DocumentTable({ data, onOpenDrawer, docStatusMap, portfo
|
|||
pageIndex: 0,
|
||||
pageSize: 25,
|
||||
});
|
||||
const [uploadDeal, setUploadDeal] = useState<ClassifiedDeal | null>(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
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contractor upload modal */}
|
||||
{uploadDeal && (
|
||||
<ContractorUploadModal
|
||||
deal={uploadDeal}
|
||||
portfolioId={portfolioId}
|
||||
onClose={() => setUploadDeal(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{pageCount > 1 && (
|
||||
<div className="flex items-center justify-between pt-1">
|
||||
|
|
|
|||
|
|
@ -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<ClassifiedDeal>[] {
|
||||
return [
|
||||
// ── Address ──────────────────────────────────────────────────────────
|
||||
|
|
@ -144,24 +143,5 @@ export function createDocumentTableColumns(
|
|||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
|
||||
// ── Upload button (contractor only) ──────────────────────────────────
|
||||
...(onUpload ? [{
|
||||
id: "upload",
|
||||
header: () => (
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-gray-500">Upload</span>
|
||||
),
|
||||
cell: ({ row }: { row: { original: ClassifiedDeal } }) => (
|
||||
<button
|
||||
onClick={() => onUpload(row.original)}
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-medium border border-brandblue/20 text-brandblue bg-brandlightblue/20 hover:bg-brandlightblue/40 hover:border-brandblue/40 transition-all duration-150 whitespace-nowrap"
|
||||
>
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
Upload Docs
|
||||
</button>
|
||||
),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
} as ColumnDef<ClassifiedDeal>] : []),
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
<div className="space-y-4 w-full">
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(v) => setActiveTab(v as "analytics" | "properties" | "documents" | "measures")}
|
||||
onValueChange={(v) => setActiveTab(v as "analytics" | "properties" | "documents")}
|
||||
>
|
||||
{/* Tab bar */}
|
||||
<TabsList className="h-10 p-1 bg-brandlightblue/10 border border-brandblue/10 rounded-xl mb-6">
|
||||
|
|
@ -123,13 +119,6 @@ export default function LiveTracker({
|
|||
<FolderOpen className="h-3.5 w-3.5" />
|
||||
Document Management
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="measures"
|
||||
className="flex items-center gap-2 rounded-lg text-sm font-medium px-4 data-[state=active]:bg-white data-[state=active]:text-brandblue data-[state=active]:shadow-sm transition-all"
|
||||
>
|
||||
<Wrench className="h-3.5 w-3.5" />
|
||||
Measures
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Analytics tab */}
|
||||
|
|
@ -215,42 +204,6 @@ export default function LiveTracker({
|
|||
data={currentProject?.allDeals ?? []}
|
||||
onOpenDrawer={handleOpenDrawer}
|
||||
docStatusMap={docStatusMap}
|
||||
portfolioId={portfolioId}
|
||||
userCapability={userCapability}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Measures tab */}
|
||||
<TabsContent value="measures" className="mt-0">
|
||||
<div className="space-y-4">
|
||||
{projects.length > 1 && (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-gray-500 shrink-0">Project:</span>
|
||||
<select
|
||||
value={currentProjectCode}
|
||||
onChange={(e) => setCurrentProjectCode(e.target.value)}
|
||||
className="px-3 py-1.5 border border-brandblue/20 rounded-lg bg-white text-sm text-gray-800 font-medium focus:ring-2 focus:ring-brandblue focus:border-brandblue focus:outline-none transition-all appearance-none pr-8"
|
||||
>
|
||||
{projectCodes.map((code) =>
|
||||
code === "__ALL__" ? (
|
||||
<option key="__ALL__" value="__ALL__" style={{ fontWeight: 700 }}>
|
||||
★ All Projects
|
||||
</option>
|
||||
) : (
|
||||
<option key={code} value={code}>
|
||||
{code}
|
||||
</option>
|
||||
),
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<MeasuresTable
|
||||
data={currentProject?.allDeals ?? []}
|
||||
userCapability={userCapability}
|
||||
approvalsByDeal={approvalsByDeal}
|
||||
portfolioId={portfolioId}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
|
@ -358,7 +311,6 @@ export default function LiveTracker({
|
|||
{/* ── Property detail drawer ─────────────────────────────────────── */}
|
||||
<PropertyDetailDrawer
|
||||
deal={detailDeal}
|
||||
portfolioId={portfolioId}
|
||||
onClose={() => setDetailDeal(null)}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Badge className="bg-amber-50 text-amber-700 border border-amber-200 text-xs">
|
||||
Pending
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
if (approvedCount === proposed.length) {
|
||||
return (
|
||||
<Badge className="bg-emerald-50 text-emerald-700 border border-emerald-200 text-xs">
|
||||
Fully Approved
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Badge className="bg-blue-50 text-blue-700 border border-blue-200 text-xs">
|
||||
{approvedCount}/{proposed.length} Approved
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<p className="text-xs text-gray-400 py-2 pl-4">Loading activity…</p>
|
||||
);
|
||||
}
|
||||
|
||||
const events = data?.events ?? [];
|
||||
|
||||
if (events.length === 0) {
|
||||
return (
|
||||
<p className="text-xs text-gray-400 py-2 pl-4">No activity yet.</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pl-4 pr-2 pb-3 space-y-1.5">
|
||||
{events.map((e) => (
|
||||
<div key={e.id} className="flex items-center gap-2 text-xs">
|
||||
<span
|
||||
className={`px-1.5 py-0.5 rounded text-xs font-medium ${
|
||||
e.action === "approved"
|
||||
? "bg-emerald-50 text-emerald-700"
|
||||
: "bg-red-50 text-red-600"
|
||||
}`}
|
||||
>
|
||||
{e.action === "approved" ? "Approved" : "Unapproved"}
|
||||
</span>
|
||||
<span className="font-medium text-gray-700">{e.measureName}</span>
|
||||
<span className="text-gray-400">·</span>
|
||||
<span className="text-gray-500">
|
||||
{e.actedByName ?? e.actedByEmail}
|
||||
</span>
|
||||
<span className="text-gray-400">·</span>
|
||||
<span className="text-gray-400">{formatDate(e.actedAt)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<measureName> (the full intended approved set)
|
||||
const [pendingChanges, setPendingChanges] = useState<
|
||||
Record<string, Set<string>>
|
||||
>({});
|
||||
const [savedApprovals, setSavedApprovals] =
|
||||
useState<ApprovalsByDeal>(approvalsByDeal);
|
||||
const [showConfirm, setShowConfirm] = useState(false);
|
||||
const [expandedRows, setExpandedRows] = useState<Set<string>>(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<Record<string, PendingDiff>>(() => {
|
||||
const diffs: Record<string, PendingDiff> = {};
|
||||
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<Record<string, string>>(() => {
|
||||
const map: Record<string, string> = {};
|
||||
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 (
|
||||
<div className="rounded-xl border border-gray-100 bg-white p-12 text-center">
|
||||
<p className="text-sm text-gray-400">
|
||||
No properties with proposed measures found in this project.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||
<div className="relative flex-1 min-w-48 max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
placeholder="Search address or measure…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-9 h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-gray-400">
|
||||
{filtered.length} of {dealsWithMeasures.length} properties
|
||||
</span>
|
||||
{userCapability.includes("approver") && hasPendingChanges && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setShowConfirm(true)}
|
||||
className="bg-brandblue text-white gap-1.5"
|
||||
>
|
||||
<Save className="h-3.5 w-3.5" />
|
||||
Review changes ({Object.keys(pendingDiffs).length})
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="rounded-xl border border-gray-100 overflow-hidden bg-white">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-gray-50 border-b border-gray-100">
|
||||
<TableHead className="w-6" />
|
||||
<TableHead className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
|
||||
Address
|
||||
</TableHead>
|
||||
<TableHead className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
|
||||
Stage
|
||||
</TableHead>
|
||||
<TableHead className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
|
||||
Proposed Measures
|
||||
</TableHead>
|
||||
<TableHead className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
|
||||
Status
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{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 (
|
||||
<React.Fragment key={deal.dealId}>
|
||||
<TableRow
|
||||
className={`border-b border-gray-50 hover:bg-gray-50/50 transition-colors ${hasPending ? "bg-amber-50/30" : ""}`}
|
||||
>
|
||||
{/* Expand toggle */}
|
||||
<TableCell className="py-3 pl-3 pr-0 w-6">
|
||||
<button
|
||||
onClick={() => toggleRowExpand(deal.dealId)}
|
||||
className="text-gray-400 hover:text-brandblue transition-colors"
|
||||
aria-label={isExpanded ? "Collapse activity" : "Expand activity"}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</TableCell>
|
||||
|
||||
{/* Address */}
|
||||
<TableCell className="py-3">
|
||||
<div className="font-medium text-sm text-gray-800">
|
||||
{deal.dealname ?? "—"}
|
||||
</div>
|
||||
{deal.landlordPropertyId && (
|
||||
<div className="text-xs text-gray-400 mt-0.5">
|
||||
{deal.landlordPropertyId}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
{/* Stage */}
|
||||
<TableCell className="py-3">
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 px-2 py-1 rounded-full text-xs font-medium border ${stageColor.bg} ${stageColor.text} ${stageColor.border}`}
|
||||
>
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${stageColor.dot}`} />
|
||||
{deal.displayStage}
|
||||
</span>
|
||||
</TableCell>
|
||||
|
||||
{/* Proposed measures */}
|
||||
<TableCell className="py-3">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{proposed.map((measure) => {
|
||||
const isApproved = approvedSet.has(measure);
|
||||
if (userCapability.includes("approver")) {
|
||||
return (
|
||||
<label
|
||||
key={measure}
|
||||
className={`flex items-center gap-1.5 cursor-pointer px-2 py-1 rounded-full text-xs border transition-colors ${
|
||||
isApproved
|
||||
? "bg-emerald-50 border-emerald-200 text-emerald-700"
|
||||
: "bg-gray-50 border-gray-200 text-gray-600 hover:bg-gray-100"
|
||||
}`}
|
||||
>
|
||||
<Checkbox
|
||||
checked={isApproved}
|
||||
onCheckedChange={() => toggleMeasure(deal.dealId, measure)}
|
||||
className="h-3 w-3"
|
||||
/>
|
||||
{measure}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span
|
||||
key={measure}
|
||||
className={`px-2 py-1 rounded-full text-xs border ${
|
||||
isApproved
|
||||
? "bg-emerald-50 border-emerald-200 text-emerald-700"
|
||||
: "bg-gray-50 border-gray-200 text-gray-600"
|
||||
}`}
|
||||
>
|
||||
{measure}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
{/* Status */}
|
||||
<TableCell className="py-3">
|
||||
<ApprovalStatus proposed={proposed} approved={approvedForDeal} />
|
||||
</TableCell>
|
||||
|
||||
</TableRow>
|
||||
|
||||
{/* Expandable activity log row */}
|
||||
{isExpanded && (
|
||||
<TableRow className="bg-gray-50/50">
|
||||
<TableCell
|
||||
colSpan={5}
|
||||
className="p-0"
|
||||
>
|
||||
<div className="border-t border-gray-100">
|
||||
<p className="text-xs font-semibold text-gray-400 uppercase tracking-wide px-4 pt-2 pb-1">
|
||||
Activity log
|
||||
</p>
|
||||
<ActivityLog dealId={deal.dealId} portfolioId={portfolioId} />
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Confirmation dialog */}
|
||||
<ApprovalConfirmDialog
|
||||
open={showConfirm}
|
||||
pendingDiffs={pendingDiffs}
|
||||
dealNames={dealNames}
|
||||
onConfirm={() => saveMutation.mutate()}
|
||||
onCancel={() => setShowConfirm(false)}
|
||||
isPending={saveMutation.isPending}
|
||||
/>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 <p className="text-xs text-gray-400 py-2">Loading activity…</p>;
|
||||
}
|
||||
|
||||
const events = data?.events ?? [];
|
||||
|
||||
if (events.length === 0) {
|
||||
return (
|
||||
<p className="text-xs text-gray-400 py-2">No approval activity yet.</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2 pt-1">
|
||||
{events.map((e) => (
|
||||
<div key={e.id} className="flex items-start gap-2 text-xs">
|
||||
<span
|
||||
className={`mt-0.5 shrink-0 px-1.5 py-0.5 rounded text-xs font-medium ${
|
||||
e.action === "approved"
|
||||
? "bg-emerald-50 text-emerald-700"
|
||||
: "bg-red-50 text-red-600"
|
||||
}`}
|
||||
>
|
||||
{e.action === "approved" ? "Approved" : "Unapproved"}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<span className="font-medium text-gray-700">{e.measureName}</span>
|
||||
<div className="text-gray-400 mt-0.5">
|
||||
{e.actedByName ?? e.actedByEmail} · {formatDateTime(e.actedAt)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 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 (
|
||||
<Drawer open={open} onOpenChange={(v) => { if (!v) { setIsLogOpen(false); onClose(); } }} direction="right">
|
||||
<Drawer open={open} onOpenChange={(v) => !v && onClose()} direction="right">
|
||||
<DrawerContent className="fixed right-0 top-0 bottom-0 h-full w-[42vw] min-w-80 max-w-lg rounded-l-2xl rounded-r-none mt-0 flex flex-col border-l border-t-0 border-b-0 border-r-0 border-brandblue/10 bg-white shadow-2xl overflow-hidden">
|
||||
<div className="hidden" />
|
||||
|
||||
|
|
@ -337,28 +255,6 @@ export default function PropertyDetailDrawer({ deal, portfolioId, onClose }: Pro
|
|||
<h3 className="text-xs font-bold uppercase tracking-wider text-gray-400 mb-4">Project Timeline</h3>
|
||||
<MilestoneTimeline deal={deal} />
|
||||
</div>
|
||||
|
||||
{/* Approval log — collapsible */}
|
||||
<div className="border-t border-gray-100 pt-4">
|
||||
<button
|
||||
onClick={() => setIsLogOpen((v) => !v)}
|
||||
className="flex items-center gap-2 w-full text-left group"
|
||||
>
|
||||
{isLogOpen ? (
|
||||
<ChevronDown className="h-3.5 w-3.5 text-gray-400 group-hover:text-brandblue transition-colors shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5 text-gray-400 group-hover:text-brandblue transition-colors shrink-0" />
|
||||
)}
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider text-gray-400 group-hover:text-brandblue transition-colors">
|
||||
Approval Log
|
||||
</h3>
|
||||
</button>
|
||||
{isLogOpen && (
|
||||
<div className="mt-3">
|
||||
<ApprovalLogSection dealId={deal.dealId} portfolioId={portfolioId} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="max-w-7xl mx-auto px-6 pb-10 space-y-4">
|
||||
{pageHeader}
|
||||
<LiveTracker
|
||||
{...trackerData}
|
||||
docStatusMap={docStatusMap}
|
||||
userCapability={userCapability}
|
||||
approvalsByDeal={approvalsByDeal}
|
||||
portfolioId={portfolioId}
|
||||
/>
|
||||
<LiveTracker {...trackerData} docStatusMap={docStatusMap} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -375,7 +375,7 @@ export function computeOutcomeSlices(deals: ClassifiedDeal[]): OutcomeSlice[] {
|
|||
// -----------------------------------------------------------------------
|
||||
export function computeLiveTrackerData(
|
||||
rawDeals: HubspotDeal[]
|
||||
): Omit<LiveTrackerProps, "docStatusMap" | "userCapability" | "approvalsByDeal" | "portfolioId"> {
|
||||
): Omit<LiveTrackerProps, "docStatusMap"> {
|
||||
// Classify all deals (add displayStage field)
|
||||
const classified = classifyDeals(rawDeals);
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string, string[]>;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 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;
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue