mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-30 12:55:02 +00:00
added trigger of sqs
This commit is contained in:
parent
9a13e0bf3f
commit
b81a1aaf61
17 changed files with 1729 additions and 38 deletions
9
CLAUDE.md
Normal file
9
CLAUDE.md
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
# Claude guidance for this project
|
||||
|
||||
## React
|
||||
|
||||
- **Avoid `useEffect` and `useMemo`.** Derive values inline, use Server Components + Route Handlers, event handlers, or `useSyncExternalStore` instead. If a hook is genuinely the only option, flag it and ask before using it.
|
||||
|
||||
## Next.js 15 route handlers
|
||||
|
||||
- `params` is a `Promise` — type as `{ params: Promise<{ ... }> }` and `await params` before destructuring.
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
import { db } from "@/app/db/db";
|
||||
import { bulkAddressUploads } from "@/app/db/schema/bulk_address_uploads";
|
||||
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 { sendToQueue } from "@/app/utils/sqs";
|
||||
|
||||
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 { uploadId } = await params;
|
||||
|
||||
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.taskId)
|
||||
return NextResponse.json({ error: "Upload has no task" }, { status: 422 });
|
||||
if (upload.combinedOutputS3Uri)
|
||||
return NextResponse.json({ alreadyCombined: true }, { status: 200 });
|
||||
|
||||
const queueName = process.env.BULK_ADDRESS2UPRN_COMBINER_QUEUE_NAME;
|
||||
if (!queueName) {
|
||||
console.error("BULK_ADDRESS2UPRN_COMBINER_QUEUE_NAME not set");
|
||||
return NextResponse.json({ error: "Server misconfiguration" }, { status: 500 });
|
||||
}
|
||||
|
||||
const [subTask] = await db
|
||||
.insert(subTasks)
|
||||
.values({
|
||||
taskId: upload.taskId,
|
||||
status: "waiting",
|
||||
})
|
||||
.returning();
|
||||
|
||||
const messageBody = { task_id: upload.taskId, sub_task_id: subTask.id };
|
||||
|
||||
try {
|
||||
await sendToQueue(messageBody, { queueName });
|
||||
} catch (err) {
|
||||
console.error("Failed to send combiner SQS message:", err);
|
||||
return NextResponse.json({ error: "Failed to queue combiner job" }, { status: 500 });
|
||||
}
|
||||
|
||||
await db
|
||||
.update(subTasks)
|
||||
.set({ inputs: JSON.stringify(messageBody) })
|
||||
.where(eq(subTasks.id, subTask.id));
|
||||
|
||||
return NextResponse.json({ taskId: upload.taskId, subTaskId: subTask.id }, { status: 200 });
|
||||
}
|
||||
|
|
@ -0,0 +1,163 @@
|
|||
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 });
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
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: Promise<{ portfolioId: string; uploadId: string }> }
|
||||
) {
|
||||
const { uploadId } = await 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 });
|
||||
}
|
||||
}
|
||||
24
src/app/api/portfolio/[portfolioId]/bulk-uploads/route.ts
Normal file
24
src/app/api/portfolio/[portfolioId]/bulk-uploads/route.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
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: Promise<{ portfolioId: string }> }
|
||||
) {
|
||||
const { portfolioId } = await 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 });
|
||||
}
|
||||
}
|
||||
40
src/app/api/tasks/[taskId]/summary/route.ts
Normal file
40
src/app/api/tasks/[taskId]/summary/route.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
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,7 +1,61 @@
|
|||
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 {
|
||||
|
|
|
|||
50
src/app/api/upload/bulk-addresses/confirm/route.ts
Normal file
50
src/app/api/upload/bulk-addresses/confirm/route.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
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 });
|
||||
}
|
||||
}
|
||||
36
src/app/api/upload/bulk-addresses/route.ts
Normal file
36
src/app/api/upload/bulk-addresses/route.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -4,11 +4,32 @@ import {
|
|||
Dialog,
|
||||
DialogBackdrop,
|
||||
DialogPanel,
|
||||
DialogTitle,
|
||||
Transition,
|
||||
TransitionChild,
|
||||
} from "@headlessui/react";
|
||||
import { Fragment } from "react";
|
||||
import { XMarkIcon, RectangleStackIcon } from "@heroicons/react/24/outline";
|
||||
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;
|
||||
|
|
@ -16,13 +37,212 @@ interface BulkUploadComingSoonModalProps {
|
|||
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 show={isOpen} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-50" onClose={onClose}>
|
||||
<Transition appear show={isOpen} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-[9999]" onClose={handleClose}>
|
||||
{/* Backdrop */}
|
||||
<TransitionChild
|
||||
as={Fragment}
|
||||
enter="ease-out duration-200"
|
||||
|
|
@ -32,52 +252,204 @@ export default function BulkUploadComingSoonModal({
|
|||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<DialogBackdrop className="fixed inset-0 bg-black/30 backdrop-blur-sm" />
|
||||
<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"
|
||||
enterTo="opacity-100 scale-100"
|
||||
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"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
leaveFrom="opacity-100 scale-100 translate-y-0"
|
||||
leaveTo="opacity-0 scale-95 translate-y-2"
|
||||
>
|
||||
<DialogPanel className="w-full max-w-md bg-white rounded-2xl shadow-xl p-8 relative">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 p-1.5 rounded-lg text-gray-400 hover:text-gray-700 hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<XMarkIcon className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
<div className="flex flex-col items-center text-center gap-4">
|
||||
<div className="w-14 h-14 rounded-2xl bg-amber-50 flex items-center justify-center">
|
||||
<RectangleStackIcon className="h-7 w-7 text-amber-500" />
|
||||
</div>
|
||||
<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>
|
||||
<span className="text-[11px] font-semibold text-amber-700 bg-amber-100 px-2 py-0.5 rounded-full">
|
||||
Coming Soon
|
||||
</span>
|
||||
<h2 className="mt-3 text-2xl font-extrabold text-gray-900 tracking-tight">
|
||||
Bulk Address Upload
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-gray-500 leading-relaxed">
|
||||
Upload multiple addresses in one go. This feature is currently in development
|
||||
and will be available soon.
|
||||
<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={onClose}
|
||||
className="mt-2 px-6 py-2.5 rounded-xl bg-gradient-to-br from-[#14163d] to-[#15173e] text-white text-sm font-bold hover:opacity-90 transition-opacity"
|
||||
onClick={handleClose}
|
||||
className="p-2 hover:bg-gray-100 rounded-full transition-colors ml-4 shrink-0"
|
||||
>
|
||||
Got it
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,129 @@
|
|||
"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;
|
||||
portfolioId: string;
|
||||
uploadId: string;
|
||||
isDomnaUser: boolean;
|
||||
}
|
||||
|
||||
const TERMINAL_STATUSES = new Set(["complete", "completed", "failed", "failure", "error"]);
|
||||
const FAILED_STATUSES = new Set(["failed", "failure", "error"]);
|
||||
|
||||
export default function OnboardingProgress({
|
||||
taskId,
|
||||
portfolioSlug,
|
||||
portfolioId,
|
||||
uploadId,
|
||||
isDomnaUser,
|
||||
}: Props) {
|
||||
const [data, setData] = useState<TaskData | null>(null);
|
||||
const [fetchError, setFetchError] = useState(false);
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const combineFiredRef = useRef(false);
|
||||
|
||||
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);
|
||||
const status = json.status.toLowerCase();
|
||||
if (TERMINAL_STATUSES.has(status)) {
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
if (!FAILED_STATUSES.has(status) && !combineFiredRef.current) {
|
||||
combineFiredRef.current = true;
|
||||
fetch(`/api/portfolio/${portfolioId}/bulk-uploads/${uploadId}/combine`, {
|
||||
method: "POST",
|
||||
}).catch((err) => console.error("Failed to trigger combiner:", err));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
setFetchError(true);
|
||||
}
|
||||
}
|
||||
|
||||
poll();
|
||||
intervalRef.current = setInterval(poll, 3000);
|
||||
return () => { if (intervalRef.current) clearInterval(intervalRef.current); };
|
||||
}, [taskId, portfolioId, uploadId]);
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
"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>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,271 @@
|
|||
"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>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,169 @@
|
|||
"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}
|
||||
portfolioId={upload.portfolioId}
|
||||
uploadId={uploadId}
|
||||
isDomnaUser={isDomnaUser}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
143
src/app/portfolio/[slug]/(portfolio)/bulk-upload/page.tsx
Normal file
143
src/app/portfolio/[slug]/(portfolio)/bulk-upload/page.tsx
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
"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>
|
||||
);
|
||||
}
|
||||
|
|
@ -16,19 +16,20 @@ const sqsClient = new SQSClient({
|
|||
},
|
||||
});
|
||||
|
||||
let cachedQueueUrl: string | null = null;
|
||||
const queueUrlCache = new Map<string, string>();
|
||||
|
||||
// Export if you want to reuse elsewhere
|
||||
export async function getQueueUrl(queueName: string): Promise<string> {
|
||||
if (cachedQueueUrl) return cachedQueueUrl;
|
||||
const cached = queueUrlCache.get(queueName);
|
||||
if (cached) return cached;
|
||||
|
||||
const resp = await sqsClient.send(
|
||||
new GetQueueUrlCommand({ QueueName: queueName })
|
||||
);
|
||||
if (!resp.QueueUrl)
|
||||
throw new Error(`Could not resolve SQS URL for queue: ${queueName}`);
|
||||
cachedQueueUrl = resp.QueueUrl;
|
||||
return cachedQueueUrl;
|
||||
queueUrlCache.set(queueName, resp.QueueUrl);
|
||||
return resp.QueueUrl;
|
||||
}
|
||||
|
||||
type SendOptions = {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue