383 lines
17 KiB
TypeScript
383 lines
17 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useRef, useCallback, useEffect } from "react";
|
|
import { createStopsBatch } from "@/actions/stops";
|
|
import GlassModal from "@/components/admin/GlassModal";
|
|
import type { ParsedStopRow } from "@/lib/csv-parsers";
|
|
|
|
type ParsedStop = Omit<ParsedStopRow, "_rowIndex" | "_warnings">;
|
|
|
|
type Step = "idle" | "parsing" | "review" | "importing" | "done" | "error";
|
|
|
|
type Props = {
|
|
brandId: string;
|
|
onClose: () => void;
|
|
onComplete: (count: number) => void;
|
|
};
|
|
|
|
export default function ScheduleImportModal({ brandId, onClose, onComplete }: Props) {
|
|
const [step, setStep] = useState<Step>("idle");
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [parsedStops, setParsedStops] = useState<ParsedStop[]>([]);
|
|
const [warnings, setWarnings] = useState<string[]>([]);
|
|
const [dragOver, setDragOver] = useState(false);
|
|
const [useAI, setUseAI] = useState(false);
|
|
const [created, setCreated] = useState(0);
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
const processFile = useCallback(async (file: File) => {
|
|
setError(null);
|
|
setStep("parsing");
|
|
|
|
let text: string;
|
|
try {
|
|
text = await file.text();
|
|
} catch {
|
|
setError("Could not read file. Try a different format.");
|
|
setStep("idle");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const res = await fetch("/api/stops/import", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ text, brandId, useAI }),
|
|
});
|
|
|
|
const data = await res.json();
|
|
|
|
if (!res.ok || (data.error && data.stops?.length === 0)) {
|
|
setError(data.error ?? "Parsing failed. Try a CSV file.");
|
|
setStep("idle");
|
|
return;
|
|
}
|
|
|
|
if (!data.stops || data.stops.length === 0) {
|
|
setError("No stops found in file. Check that columns include: city, state, location, date, time.");
|
|
setStep("idle");
|
|
return;
|
|
}
|
|
|
|
setParsedStops(data.stops);
|
|
setWarnings(data.warnings ?? []);
|
|
setStep("review");
|
|
} catch {
|
|
setError("Network error while parsing file.");
|
|
setStep("idle");
|
|
}
|
|
}, [brandId, useAI]);
|
|
|
|
function handleFile(file: File) {
|
|
const ext = file.name.split(".").pop()?.toLowerCase() ?? "";
|
|
if (!["csv", "txt", "json"].includes(ext)) {
|
|
setError("Unsupported file type. Please upload a CSV, TXT, or JSON file.");
|
|
return;
|
|
}
|
|
processFile(file);
|
|
}
|
|
|
|
function handleDrop(e: React.DragEvent) {
|
|
e.preventDefault();
|
|
setDragOver(false);
|
|
const file = e.dataTransfer.files[0];
|
|
if (file) handleFile(file);
|
|
}
|
|
|
|
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
|
|
const file = e.target.files?.[0];
|
|
if (file) handleFile(file);
|
|
}
|
|
|
|
function updateStop(idx: number, field: keyof ParsedStop, value: string) {
|
|
setParsedStops((prev) => {
|
|
const next = [...prev];
|
|
next[idx] = { ...next[idx], [field]: value };
|
|
return next;
|
|
});
|
|
}
|
|
|
|
function removeStop(idx: number) {
|
|
setParsedStops((prev) => prev.filter((_, i) => i !== idx));
|
|
}
|
|
|
|
async function handleImport() {
|
|
if (parsedStops.length === 0) return;
|
|
setStep("importing");
|
|
setError(null);
|
|
|
|
const result = await createStopsBatch(
|
|
brandId,
|
|
parsedStops.map(({ city, state, location, date, time, address, zip, notes }) => ({
|
|
city, state, location, date, time, address, zip, notes,
|
|
}))
|
|
);
|
|
|
|
if (!result.success) {
|
|
setError(result.error ?? "Import failed");
|
|
setStep("review");
|
|
return;
|
|
}
|
|
|
|
setCreated(result.created);
|
|
setStep("done");
|
|
onComplete(result.created);
|
|
}
|
|
|
|
const hasWarnings = warnings.length > 0;
|
|
|
|
return (
|
|
<GlassModal
|
|
title="📥 Import Schedule"
|
|
subtitle="Upload a CSV to bulk-create stops as drafts"
|
|
onClose={onClose}
|
|
>
|
|
<div className="space-y-5">
|
|
{step === "idle" && (
|
|
<>
|
|
{error && (
|
|
<div className="rounded-xl bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-600">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{/* AI toggle */}
|
|
<div className="flex items-center gap-3 p-4 rounded-xl border border-stone-200 bg-stone-50">
|
|
<button type="button"
|
|
onClick={() => setUseAI((v) => !v)}
|
|
className={`flex items-center gap-2.5 rounded-xl px-4 py-2.5 text-sm font-medium transition-all ${
|
|
useAI
|
|
? "bg-violet-100 border border-violet-300 text-violet-700"
|
|
: "bg-white border border-stone-200 text-stone-600 hover:border-stone-300"
|
|
}`}
|
|
aria-label="Use AI for text/PDF parsing">
|
|
<span className={`flex h-5 w-5 items-center justify-center rounded-full text-xs ${useAI ? "bg-violet-500 text-white" : "bg-stone-200 text-stone-500"}`}>
|
|
{useAI ? "✓" : "○"}
|
|
</span>
|
|
Use AI for text/PDF parsing
|
|
</button>
|
|
<span className="text-xs text-stone-500">
|
|
{useAI ? "AI will parse unstructured text. Requires OPENAI_API_KEY." : "Best results with CSV."}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Drop zone */}
|
|
<label
|
|
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
|
|
onDragLeave={() => setDragOver(false)}
|
|
onDrop={handleDrop}
|
|
className={`cursor-pointer rounded-xl border-2 border-dashed p-8 text-center transition-all ${
|
|
dragOver
|
|
? "border-emerald-500 bg-emerald-50"
|
|
: "border-stone-300 hover:border-emerald-400 hover:bg-stone-50"
|
|
}`}
|
|
>
|
|
<div className="flex justify-center mb-3">
|
|
<div className={`flex h-12 w-12 items-center justify-center rounded-xl ${dragOver ? "bg-emerald-100" : "bg-stone-100"}`}>
|
|
<svg className={`h-6 w-6 ${dragOver ? "text-emerald-600" : "text-stone-400"}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 002.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 00-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75 2.25 2.25 0 00-.1-.664m-5.801 0A2.251 2.251 0 0113.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25zM6.75 12h.008v.008H6.75V12zm0 3h.008v.008H6.75V15zm0 3h.008v.008H6.75V18z" />
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
<p className="text-sm font-semibold text-stone-700">Drop your schedule file here</p>
|
|
<p className="mt-1 text-xs text-stone-500">or click to browse — CSV, TXT, JSON</p>
|
|
<p className="mt-3 text-[10px] text-stone-400 font-mono bg-stone-100 rounded-lg px-3 py-1.5 inline-block">
|
|
CSV: city, state, location, date, time, address, zip, notes
|
|
</p>
|
|
</label>
|
|
|
|
<input aria-label="File upload"
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept=".csv,.txt,.json"
|
|
className="sr-only"
|
|
onChange={handleChange}
|
|
/>
|
|
</>
|
|
)}
|
|
|
|
{step === "parsing" && (
|
|
<div className="flex items-center justify-center py-12">
|
|
<div className="flex items-center gap-3">
|
|
<svg className="h-5 w-5 animate-spin text-emerald-600" fill="none" viewBox="0 0 24 24">
|
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
|
</svg>
|
|
<span className="text-sm font-medium text-stone-600">Parsing file…</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{step === "review" && (
|
|
<div className="space-y-4">
|
|
{error && (
|
|
<div className="rounded-xl bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-600">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{hasWarnings && (
|
|
<div className="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm">
|
|
<p className="font-semibold text-amber-800">Parsing warnings:</p>
|
|
<ul className="mt-1 list-disc list-inside space-y-0.5 text-amber-700">
|
|
{warnings.slice(0, 5).map((w, i) => (
|
|
<li key={`${w}-${i}`}>{w}</li>
|
|
))}
|
|
{warnings.length > 5 && <li>…and {warnings.length - 5} more</li>}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex items-center justify-between">
|
|
<p className="text-sm font-semibold text-stone-700">
|
|
{parsedStops.length} stop{parsedStops.length !== 1 ? "s" : ""} found — review and edit
|
|
</p>
|
|
<span className="text-xs text-stone-400">All will be created as drafts</span>
|
|
</div>
|
|
|
|
{/* Review table */}
|
|
<div className="max-h-64 overflow-y-auto rounded-xl border border-stone-200 bg-white">
|
|
<table className="w-full text-sm">
|
|
<thead className="sticky top-0 bg-stone-50 border-b border-stone-100">
|
|
<tr>
|
|
<th className="px-3 py-2.5 text-left text-[10px] font-bold uppercase tracking-wider text-stone-500">City</th>
|
|
<th className="px-3 py-2.5 text-left text-[10px] font-bold uppercase tracking-wider text-stone-500">State</th>
|
|
<th className="px-3 py-2.5 text-left text-[10px] font-bold uppercase tracking-wider text-stone-500">Location</th>
|
|
<th className="px-3 py-2.5 text-left text-[10px] font-bold uppercase tracking-wider text-stone-500">Date</th>
|
|
<th className="px-3 py-2.5 text-left text-[10px] font-bold uppercase tracking-wider text-stone-500">Time</th>
|
|
<th className="w-8 px-3 py-2.5" aria-label="Actions" />
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-stone-50">
|
|
{parsedStops.map((stop, idx) => (
|
|
<tr key={`${stop.city}-${stop.state}-${stop.date}-${stop.time}-${idx}`} className="hover:bg-stone-50/50 transition-colors">
|
|
<td className="px-2 py-1.5">
|
|
<input aria-label="Input"
|
|
value={stop.city}
|
|
onChange={(e) => updateStop(idx, "city", e.target.value)}
|
|
className="w-full rounded-lg border border-stone-200 bg-white px-2 py-1.5 text-sm outline-none focus:border-emerald-400"
|
|
/>
|
|
</td>
|
|
<td className="px-2 py-1.5">
|
|
<input aria-label="Input"
|
|
value={stop.state}
|
|
onChange={(e) => updateStop(idx, "state", e.target.value)}
|
|
className="w-12 rounded-lg border border-stone-200 bg-white px-2 py-1.5 text-sm outline-none focus:border-emerald-400"
|
|
/>
|
|
</td>
|
|
<td className="px-2 py-1.5">
|
|
<input aria-label="Input"
|
|
value={stop.location}
|
|
onChange={(e) => updateStop(idx, "location", e.target.value)}
|
|
className="w-full rounded-lg border border-stone-200 bg-white px-2 py-1.5 text-sm outline-none focus:border-emerald-400"
|
|
/>
|
|
</td>
|
|
<td className="px-2 py-1.5">
|
|
<input aria-label="Input"
|
|
value={stop.date}
|
|
onChange={(e) => updateStop(idx, "date", e.target.value)}
|
|
className="w-24 rounded-lg border border-stone-200 bg-white px-2 py-1.5 text-sm outline-none focus:border-emerald-400"
|
|
/>
|
|
</td>
|
|
<td className="px-2 py-1.5">
|
|
<input aria-label="Input"
|
|
value={stop.time}
|
|
onChange={(e) => updateStop(idx, "time", e.target.value)}
|
|
className="w-16 rounded-lg border border-stone-200 bg-white px-2 py-1.5 text-sm outline-none focus:border-emerald-400"
|
|
/>
|
|
</td>
|
|
<td className="px-2 py-1.5">
|
|
<button type="button"
|
|
onClick={() => removeStop(idx)}
|
|
className="flex h-6 w-6 items-center justify-center rounded-lg text-red-500 hover:bg-red-50 hover:text-red-700 transition-colors"
|
|
aria-label="Close">
|
|
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between pt-2 border-t border-stone-100">
|
|
<p className="text-xs text-stone-500">
|
|
{parsedStops.length} draft stop{parsedStops.length !== 1 ? "s" : ""} will be created
|
|
</p>
|
|
<div className="flex gap-2">
|
|
<button type="button"
|
|
onClick={() => { setStep("idle"); setParsedStops([]); setWarnings([]); }}
|
|
className="rounded-xl border border-stone-200 bg-white px-4 py-2 text-sm font-semibold text-stone-600 hover:bg-stone-50 transition-colors"
|
|
aria-label="Cancel">
|
|
Cancel
|
|
</button>
|
|
<button type="button"
|
|
onClick={handleImport}
|
|
disabled={parsedStops.length === 0}
|
|
className="rounded-xl bg-emerald-600 hover:bg-emerald-700 px-5 py-2 text-sm font-bold text-white disabled:opacity-50 transition-colors"
|
|
aria-label="Create Stop">
|
|
Create {parsedStops.length} Stop{parsedStops.length !== 1 ? "s" : ""}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{step === "importing" && (
|
|
<div className="flex items-center justify-center py-12">
|
|
<div className="flex items-center gap-3">
|
|
<svg className="h-5 w-5 animate-spin text-emerald-600" fill="none" viewBox="0 0 24 24">
|
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
|
</svg>
|
|
<span className="text-sm font-medium text-stone-600">Creating draft stops…</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{step === "done" && (
|
|
<div className="flex flex-col items-center gap-4 py-8 text-center">
|
|
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-green-50">
|
|
<svg className="h-7 w-7 text-emerald-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
|
</svg>
|
|
</div>
|
|
<div>
|
|
<p className="text-xl font-bold text-stone-950">
|
|
{created} draft stop{created !== 1 ? "s" : ""} created
|
|
</p>
|
|
<p className="mt-1 text-sm text-stone-500">
|
|
Review them in the stops list and publish when ready.
|
|
</p>
|
|
</div>
|
|
<button type="button"
|
|
onClick={onClose}
|
|
className="rounded-xl bg-emerald-600 hover:bg-emerald-700 px-5 py-2.5 text-sm font-bold text-white transition-colors"
|
|
aria-label="Back to Stops">
|
|
Back to Stops
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{step === "error" && (
|
|
<div className="flex flex-col items-center gap-4 py-8 text-center">
|
|
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-red-50">
|
|
<svg className="h-7 w-7 text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</div>
|
|
<p className="text-sm font-medium text-red-700">{error}</p>
|
|
<button type="button"
|
|
onClick={() => setStep("idle")}
|
|
className="rounded-xl border border-stone-200 bg-white px-4 py-2 text-sm font-semibold text-stone-600 hover:bg-stone-50 transition-colors"
|
|
aria-label="Try Again">
|
|
Try Again
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</GlassModal>
|
|
);
|
|
} |