added functionality to the column mapper

This commit is contained in:
Jun-te Kim 2026-04-16 17:47:53 +00:00
parent e0449d01a2
commit c0719aaee6
10 changed files with 1301 additions and 0 deletions

98
bulk-address-upload.md Normal file
View file

@ -0,0 +1,98 @@
# 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 |

View file

@ -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: { 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 });
}
}

View 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: { 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View file

@ -0,0 +1,459 @@
"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>
);
}

View file

@ -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">
&ldquo;Ensure your source file doesn&apos;t have blank headers. Any column mapped to
&ldquo;Skip&rdquo; will be ignored during import.&rdquo;
</p>
</div>
</div>
);
}

View file

@ -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}
/>
);
}

View file

@ -0,0 +1,137 @@
"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";
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: "Queued for processing",
body: "Column mapping saved. Your file is queued and will be processed shortly.",
cta: false,
},
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 [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>
{config.cta && (
<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>
)}
</div>
</div>
</div>
</div>
);
}

View 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>
);
}