diff --git a/bulk-address-upload.md b/bulk-address-upload.md new file mode 100644 index 0000000..643a012 --- /dev/null +++ b/bulk-address-upload.md @@ -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 | diff --git a/src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/route.ts b/src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/route.ts new file mode 100644 index 0000000..520e933 --- /dev/null +++ b/src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/route.ts @@ -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 }); + } +} diff --git a/src/app/api/portfolio/[portfolioId]/bulk-uploads/route.ts b/src/app/api/portfolio/[portfolioId]/bulk-uploads/route.ts new file mode 100644 index 0000000..902de6d --- /dev/null +++ b/src/app/api/portfolio/[portfolioId]/bulk-uploads/route.ts @@ -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 }); + } +} diff --git a/src/app/api/upload/bulk-addresses/confirm/route.ts b/src/app/api/upload/bulk-addresses/confirm/route.ts new file mode 100644 index 0000000..edc6357 --- /dev/null +++ b/src/app/api/upload/bulk-addresses/confirm/route.ts @@ -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 }); + } +} diff --git a/src/app/api/upload/bulk-addresses/route.ts b/src/app/api/upload/bulk-addresses/route.ts new file mode 100644 index 0000000..9eb1c41 --- /dev/null +++ b/src/app/api/upload/bulk-addresses/route.ts @@ -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 }); + } +} diff --git a/src/app/components/portfolio/BulkUploadComingSoonModal.tsx b/src/app/components/portfolio/BulkUploadComingSoonModal.tsx new file mode 100644 index 0000000..1cd47bf --- /dev/null +++ b/src/app/components/portfolio/BulkUploadComingSoonModal.tsx @@ -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 = { + ".csv": "text/csv", + ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ".xls": "application/vnd.ms-excel", +}; + +interface BulkUploadComingSoonModalProps { + isOpen: boolean; + onClose: () => void; + portfolioId: string; +} + +function downloadTemplate() { + const ws = XLSX.utils.aoa_to_sheet([["Internal Reference (Optional)", "Address", "Postcode"]]); + const wb = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(wb, ws, "Properties"); + XLSX.writeFile(wb, "bulk_upload_template.xlsx"); +} + +function getFileExtension(filename: string): string { + return filename.slice(filename.lastIndexOf(".")).toLowerCase(); +} + +function generateS3Key(userId: string, portfolioId: string, ext: string): string { + const timestamp = new Date().toISOString().replace(/[:.-]/g, ""); + return `bulk-addresses/${userId}/${portfolioId}/${timestamp}/addresses${ext}`; +} + +function validateFile(file: File): string | null { + const sizeMB = file.size / (1024 * 1024); + if (sizeMB > MAX_FILE_SIZE_MB) { + return `File too large. Max ${MAX_FILE_SIZE_MB}MB.`; + } + const ext = getFileExtension(file.name); + if (!ALLOWED_EXTENSIONS.includes(ext)) { + return "Only CSV or Excel files allowed."; + } + return null; +} + +async function validateHeaders(file: File): Promise<{ error: string | null; headers: string[] }> { + const ext = getFileExtension(file.name); + let headers: string[] = []; + + if (ext === ".csv") { + const text = await file.text(); + const firstLine = text.split(/\r?\n/)[0] ?? ""; + headers = firstLine.split(",").map((h) => h.trim().replace(/^["']|["']$/g, "")); + } else { + const buffer = await file.arrayBuffer(); + const wb = XLSX.read(buffer, { sheetRows: 1 }); + const sheet = wb.Sheets[wb.SheetNames[0]]; + const rows = XLSX.utils.sheet_to_json(sheet, { header: 1 }); + headers = ((rows[0] as string[]) ?? []).map((h) => String(h ?? "").trim()); + } + + const normalised = headers.map((h) => h.toLowerCase()); + const hasAddress = normalised.some((h) => h.startsWith("address")); + const hasPostcode = normalised.some((h) => h === "postcode"); + + if (!hasAddress && !hasPostcode) { + return { error: "Missing required columns: Address and Postcode.", headers }; + } + if (!hasAddress) { + return { error: "Missing required column: Address (or Address 1, Address 2, etc.).", headers }; + } + if (!hasPostcode) { + return { error: 'Missing required column: "Postcode".', headers }; + } + return { error: null, headers }; +} + +async function getPresignedUrl( + userId: string, + portfolioId: string, + fileKey: string, + contentType: string +): Promise { + const res = await fetch("/api/upload/bulk-addresses", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ userId, portfolioId, fileKey, contentType }), + }); + if (!res.ok) throw new Error("Failed to generate upload URL."); + const data = await res.json(); + return data.url; +} + +export default function BulkUploadComingSoonModal({ + isOpen, + onClose, + portfolioId, +}: BulkUploadComingSoonModalProps) { + const session = useSession(); + const router = useRouter(); + const fileInputRef = useRef(null); + + const [isDragging, setIsDragging] = useState(false); + const [selectedFile, setSelectedFile] = useState(null); + const [sourceHeaders, setSourceHeaders] = useState([]); + const [validationError, setValidationError] = useState(null); + const [validating, setValidating] = useState(false); + const [uploading, setUploading] = useState(false); + const [uploadProgress, setUploadProgress] = useState(null); + const [uploadError, setUploadError] = useState(null); + + async function handleFile(file: File) { + setUploadError(null); + setSelectedFile(null); + setValidationError(null); + + const sizeOrTypeError = validateFile(file); + if (sizeOrTypeError) { + setValidationError(sizeOrTypeError); + return; + } + + setValidating(true); + const { error: headerError, headers } = await validateHeaders(file); + setValidating(false); + + if (headerError) { + setValidationError(headerError); + return; + } + + setSourceHeaders(headers); + setSelectedFile(file); + } + + function handleDragOver(e: DragEvent) { + e.preventDefault(); + setIsDragging(true); + } + + function handleDragLeave() { + setIsDragging(false); + } + + function handleDrop(e: DragEvent) { + e.preventDefault(); + setIsDragging(false); + const file = e.dataTransfer.files[0]; + if (file) handleFile(file); + } + + function handleInputChange(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + if (file) handleFile(file); + } + + function handleClose() { + setSelectedFile(null); + setSourceHeaders([]); + setValidationError(null); + setValidating(false); + setUploadError(null); + setUploading(false); + setUploadProgress(null); + onClose(); + } + + async function handleUpload() { + const userId = String(session.data?.user?.dbId ?? ""); + if (!selectedFile || !userId) return; + + setUploading(true); + setUploadProgress(0); + setUploadError(null); + + try { + const ext = getFileExtension(selectedFile.name); + const contentType = CONTENT_TYPES[ext] ?? "application/octet-stream"; + const fileKey = generateS3Key(userId, portfolioId, ext); + + const presignedUrl = await getPresignedUrl(userId, portfolioId, fileKey, contentType); + + await new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open("PUT", presignedUrl); + xhr.setRequestHeader("Content-Type", contentType); + xhr.upload.addEventListener("progress", (e) => { + if (e.lengthComputable) { + setUploadProgress(Math.round((e.loaded / e.total) * 100)); + } + }); + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) resolve(); + else reject(new Error(`S3 upload failed: ${xhr.status}`)); + }; + xhr.onerror = () => reject(new Error("Network error during upload")); + xhr.send(selectedFile); + }); + + const confirmRes = await fetch("/api/upload/bulk-addresses/confirm", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ fileKey, filename: selectedFile.name, portfolioId, userId, sourceHeaders }), + }); + if (!confirmRes.ok) throw new Error("Failed to record upload."); + + const { id: uploadId } = await confirmRes.json(); + router.push(`/portfolio/${portfolioId}/bulk-upload/${uploadId}/map-columns`); + onClose(); + } catch (err) { + setUploadError("Upload failed. Please try again, or contact a Domna representative if the issue persists."); + } finally { + setUploading(false); + setUploadProgress(null); + } + } + + const canUpload = !!selectedFile && !uploading && !validating; + + return ( + + + {/* Backdrop */} + + + + + {/* Panel */} +
+ + + + {/* Header */} +
+
+ + Bulk Upload: New Properties + +

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

+
+ +
+ + {/* Content */} +
+ + {/* Template section */} +
+
+
+ +
+
+

Required Template Format

+

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

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

Checking headers…

+

Validating column structure

+ + ) : uploading ? ( + <> +
+ +
+

Uploading…

+

{selectedFile?.name}

+
+
+
+

{uploadProgress ?? 0}%

+ + ) : validationError ? ( + <> + +

{validationError}

+

Click to choose a different file

+ + ) : selectedFile ? ( + <> + +

{selectedFile.name}

+

Click to change file

+ + ) : ( + <> +
+ +
+

+ Drag and drop CSV or XLSX +

+

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

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

+ + {uploadError} +

+ )} + + {/* Info strip */} +
+ + + Properties will be automatically validated against national + architectural databases. + +
+
+ + {/* Footer */} +
+
+ + +
+
+ +
+
+ + + +
+
+
+ ); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/map-columns/MapColumnsClient.tsx b/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/map-columns/MapColumnsClient.tsx new file mode 100644 index 0000000..1915282 --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/map-columns/MapColumnsClient.tsx @@ -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 +): Record { + const mapping: Record = {}; + for (const h of headers) { + mapping[h] = existing?.[h] ?? autoDetect(h); + } + return mapping; +} + +interface Props { + portfolioId: string; + uploadId: string; + filename: string; + sourceHeaders: string[]; + existingMapping?: Record; +} + +export default function MapColumnsClient({ + portfolioId, + uploadId, + filename, + sourceHeaders, + existingMapping, +}: Props) { + const router = useRouter(); + const [mapping, setMapping] = useState>( + buildInitialMapping(sourceHeaders, existingMapping) + ); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + const mappedValues = Object.values(mapping).filter((v) => v !== "skip"); + const missingRequired = REQUIRED_VALUES.filter((r) => !mappedValues.includes(r)); + const canSubmit = missingRequired.length === 0 && !submitting; + + function setField(header: string, value: string) { + setMapping((prev) => ({ ...prev, [header]: value })); + } + + async function handleSubmit() { + if (!canSubmit) return; + setSubmitting(true); + setError(null); + + try { + const res = await fetch( + `/api/portfolio/${portfolioId}/bulk-uploads/${uploadId}`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ columnMapping: mapping }), + } + ); + + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.msg ?? "Failed to save mapping."); + } + + router.push(`/portfolio/${portfolioId}/bulk-upload/${uploadId}`); + } catch (err) { + setError(err instanceof Error ? err.message : "Something went wrong."); + setSubmitting(false); + } + } + + return ( +
+ {/* Breadcrumb + step */} +
+

+ Bulk Uploads › Column Remapper +

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

+ Column Remapper +

+

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

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

{header}

+

Source column

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

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

+ )} + {error &&

{error}

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

+ Pro Tip +

+

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

+
+
+ ); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/map-columns/page.tsx b/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/map-columns/page.tsx new file mode 100644 index 0000000..3ce0ac6 --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/map-columns/page.tsx @@ -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 ( + + ); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/page.tsx b/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/page.tsx new file mode 100644 index 0000000..3955a57 --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/page.tsx @@ -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 ( +
+ {/* Back */} + + + Back to uploads + + + {/* Header */} +
+

+ Bulk Upload +

+

+ {upload.filename} +

+

Uploaded {formatDate(upload.createdAt)}

+
+ + {/* Status card */} +
+
+
+ +
+
+

{config.title}

+

{config.body}

+ {config.cta && ( + + Map Columns + + + )} +
+
+
+ +
+ ); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/bulk-upload/page.tsx b/src/app/portfolio/[slug]/(portfolio)/bulk-upload/page.tsx new file mode 100644 index 0000000..cc5a949 --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/bulk-upload/page.tsx @@ -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 = { + ready_for_processing: { label: "Ready", classes: "bg-amber-100 text-amber-700" }, + processing: { label: "Processing", classes: "bg-blue-100 text-blue-700" }, + complete: { label: "Complete", classes: "bg-green-100 text-green-700" }, + failed: { label: "Failed", classes: "bg-red-100 text-red-700" }, +}; + +function formatDate(date: Date) { + return new Intl.DateTimeFormat("en-GB", { + day: "2-digit", + month: "short", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }).format(date); +} + +export default async function BulkUploadListPage(props: { + params: Promise<{ slug: string }>; +}) { + const { slug } = await props.params; + const session = await getServerSession(AuthOptions); + if (!session) redirect("/login"); + + const uploads = await db + .select() + .from(bulkAddressUploads) + .where(eq(bulkAddressUploads.portfolioId, slug)) + .orderBy(desc(bulkAddressUploads.createdAt)); + + return ( +
+ {/* Header */} +
+

+ Portfolio › Bulk Uploads +

+

+ Batch Uploads +

+

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

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

No uploads yet

+

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

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

+ {upload.filename} +

+

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

+
+
+ + {/* Date */} +
+

+ {formatDate(upload.createdAt)} +

+
+ + {/* Status badge */} +
+ + + {status.label} + +
+ + {/* Arrow */} +
+ +
+ + ); + })} +
+ )} +
+ ); +}