Initial commit - Route Commerce platform
This commit is contained in:
@@ -0,0 +1,280 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
type AIProvider = "openai" | "anthropic" | "google" | "xai" | "custom";
|
||||
|
||||
const PROVIDER_MODELS: Record<Exclude<AIProvider, "custom">, string[]> = {
|
||||
openai: ["gpt-4o", "gpt-4o-mini", "gpt-4-turbo", "gpt-3.5-turbo"],
|
||||
anthropic: ["claude-3-5-sonnet-20241002", "claude-3-5-sonnet-20250611", "claude-3-opus-20240229", "claude-3-haiku-20240307"],
|
||||
google: ["gemini-2.0-flash", "gemini-1.5-pro", "gemini-1.5-flash", "gemini-pro"],
|
||||
xai: ["grok-2", "grok-2-mini", "grok-1.5"],
|
||||
};
|
||||
|
||||
const PROVIDERS: { id: AIProvider; name: string; icon: string; description: string; website: string }[] = [
|
||||
{ id: "openai", name: "OpenAI", icon: "🤖", description: "GPT-4o, GPT-4o-mini, GPT-4 Turbo. Industry standard for general AI tasks.", website: "openai.com" },
|
||||
{ id: "anthropic", name: "Anthropic Claude", icon: "🧠", description: "Claude 3.5 Sonnet, Claude 3 Opus. Best for long-form reasoning and analysis.", website: "anthropic.com" },
|
||||
{ id: "google", name: "Google Gemini", icon: "🔶", description: "Gemini 2.0 Flash, Gemini 1.5 Pro. Fast, cost-effective, strong multimodal.", website: "ai.google" },
|
||||
{ id: "xai", name: "xAI Grok", icon: "🌪️", description: "Grok-2, Grok-1.5. Real-time data, humor, unconventional reasoning.", website: "x.ai" },
|
||||
{ id: "custom", name: "Custom / Other", icon: "🔧", description: "Connect any OpenAI-compatible API or custom LLM endpoint.", website: "" },
|
||||
];
|
||||
|
||||
type Props = {
|
||||
brandId: string;
|
||||
};
|
||||
|
||||
export default function AIProviderPanel({ brandId }: Props) {
|
||||
const [provider, setProvider] = useState<AIProvider>("openai");
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [orgId, setOrgId] = useState("");
|
||||
const [model, setModel] = useState("gpt-4o-mini");
|
||||
const [customEndpoint, setCustomEndpoint] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testResult, setTestResult] = useState<{ ok: boolean; message: string } | null>(null);
|
||||
const [showKey, setShowKey] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
try {
|
||||
const res = await fetch(`/api/integrations/ai-provider?brandId=${brandId}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setProvider(data.provider ?? "openai");
|
||||
setApiKey(data.apiKey ?? "");
|
||||
setOrgId(data.orgId ?? "");
|
||||
setModel(data.model ?? "gpt-4o-mini");
|
||||
setCustomEndpoint(data.customEndpoint ?? "");
|
||||
}
|
||||
} catch {}
|
||||
setLoading(false);
|
||||
}
|
||||
load();
|
||||
}, [brandId]);
|
||||
|
||||
async function handleSave() {
|
||||
setSaving(true);
|
||||
setTestResult(null);
|
||||
try {
|
||||
const res = await fetch("/api/integrations/ai-provider", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ brandId, provider, apiKey, orgId, model, customEndpoint }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error ?? "Save failed");
|
||||
setTestResult({ ok: true, message: "AI provider settings saved" });
|
||||
} catch (err) {
|
||||
setTestResult({ ok: false, message: err instanceof Error ? err.message : "Save failed" });
|
||||
}
|
||||
setSaving(false);
|
||||
}
|
||||
|
||||
async function handleTest() {
|
||||
setTestResult(null);
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await fetch("/api/integrations/ai-provider/test", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ brandId }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error ?? "Connection failed");
|
||||
setTestResult({ ok: true, message: data.message ?? "Connection successful" });
|
||||
} catch (err) {
|
||||
setTestResult({ ok: false, message: err instanceof Error ? err.message : "Test failed" });
|
||||
}
|
||||
setSaving(false);
|
||||
}
|
||||
|
||||
const models = provider === "custom" ? ["gpt-4o-mini"] : (PROVIDER_MODELS[provider] ?? []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="rounded-2xl bg-zinc-900 p-6 shadow-black/20 ring-1 ring-zinc-700 animate-pulse">
|
||||
<div className="h-6 bg-slate-200 rounded w-1/3 mb-4" />
|
||||
<div className="h-4 bg-slate-200 rounded w-2/3 mb-6" />
|
||||
<div className="grid grid-cols-5 gap-3">
|
||||
{[1,2,3,4,5].map(i => <div key={i} className="h-16 bg-zinc-950 rounded-xl" />)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl bg-zinc-900 shadow-black/20 ring-1 ring-violet-200 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="bg-gradient-to-r from-violet-600 to-indigo-600 px-6 py-5 flex items-center gap-4">
|
||||
<span className="text-2xl">🤖</span>
|
||||
<div>
|
||||
<h2 className="text-base font-bold text-white">AI Provider</h2>
|
||||
<p className="text-xs text-violet-200 mt-0.5">Choose your AI model provider for all AI tools</p>
|
||||
</div>
|
||||
<span className="ml-auto text-xs text-violet-200 bg-zinc-900/20 rounded-full px-3 py-1">
|
||||
{PROVIDERS.find(p => p.id === provider)?.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Provider selector */}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-wider text-zinc-500 mb-3">
|
||||
Select Provider
|
||||
</label>
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
|
||||
{PROVIDERS.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => {
|
||||
setProvider(p.id);
|
||||
setModel(p.id === "custom" ? "gpt-4o-mini" : PROVIDER_MODELS[p.id as Exclude<AIProvider, "custom">]?.[0] ?? "gpt-4o-mini");
|
||||
}}
|
||||
className={`rounded-xl p-3 text-center transition-all border-2 ${
|
||||
provider === p.id
|
||||
? "border-violet-500 bg-violet-50 shadow-black/20"
|
||||
: "border-zinc-800 hover:border-violet-200 hover:bg-zinc-800"
|
||||
}`}
|
||||
>
|
||||
<span className="text-2xl block mb-1">{p.icon}</span>
|
||||
<p className="text-xs font-semibold text-slate-800">{p.name}</p>
|
||||
{p.website && (
|
||||
<p className="text-[10px] text-slate-400 mt-0.5">{p.website}</p>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Credentials — only for non-custom */}
|
||||
{provider !== "custom" && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-zinc-400 mb-1">
|
||||
API Key {provider === "anthropic" ? "(Anthropic)" : provider === "google" ? "(Google AI)" : provider === "xai" ? "(xAI)" : "(OpenAI)"}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showKey ? "text" : "password"}
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
placeholder={provider === "anthropic" ? "sk-ant-..." : provider === "google" ? "AIza..." : provider === "xai" ? "xai-..." : "sk-..."}
|
||||
className="w-full rounded-xl border border-zinc-600 px-4 py-2.5 text-sm pr-20 outline-none focus:border-violet-500"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowKey(!showKey)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-slate-400 hover:text-zinc-400"
|
||||
>
|
||||
{showKey ? "Hide" : "Show"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{provider === "openai" && (
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-zinc-400 mb-1">Organization ID (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={orgId}
|
||||
onChange={(e) => setOrgId(e.target.value)}
|
||||
placeholder="org-..."
|
||||
className="w-full rounded-xl border border-zinc-600 px-4 py-2.5 text-sm outline-none focus:border-violet-500"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Custom endpoint fields */}
|
||||
{provider === "custom" && (
|
||||
<div className="space-y-4 rounded-xl bg-slate-50 border border-zinc-800 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-lg mt-0.5">🔧</span>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-semibold text-zinc-300">Custom API Endpoint</p>
|
||||
<p className="text-xs text-zinc-500 mt-0.5">Connect any OpenAI-compatible API (Ollama, LM Studio, custom proxy, etc.)</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-zinc-400 mb-1">API Key</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showKey ? "text" : "password"}
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
placeholder="sk-..."
|
||||
className="w-full rounded-xl border border-zinc-600 px-4 py-2.5 text-sm pr-20 outline-none focus:border-violet-500"
|
||||
/>
|
||||
<button type="button" onClick={() => setShowKey(!showKey)} className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-slate-400 hover:text-zinc-400">
|
||||
{showKey ? "Hide" : "Show"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-zinc-400 mb-1">Base URL</label>
|
||||
<input
|
||||
type="text"
|
||||
value={customEndpoint}
|
||||
onChange={(e) => setCustomEndpoint(e.target.value)}
|
||||
placeholder="https://api.openai.com/v1 or http://localhost:11434/v1"
|
||||
className="w-full rounded-xl border border-zinc-600 px-4 py-2.5 text-sm outline-none focus:border-violet-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Model selector */}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-wider text-zinc-500 mb-2">
|
||||
Model
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{models.map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => setModel(m)}
|
||||
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition-colors ${
|
||||
model === m
|
||||
? "bg-violet-600 text-white"
|
||||
: "bg-zinc-950 text-zinc-400 hover:bg-slate-200"
|
||||
}`}
|
||||
>
|
||||
{m}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Test result */}
|
||||
{testResult && (
|
||||
<div className={`rounded-xl px-4 py-3 text-sm flex items-center gap-2 ${
|
||||
testResult.ok ? "bg-green-900/30 text-green-400 border border-green-200" : "bg-red-900/30 text-red-400 border border-red-200"
|
||||
}`}>
|
||||
<span>{testResult.ok ? "✓" : "✗"}</span>
|
||||
<span>{testResult.message}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3 pt-2 border-t border-slate-100">
|
||||
<button
|
||||
onClick={handleTest}
|
||||
disabled={saving}
|
||||
className="rounded-xl border border-zinc-600 px-5 py-2.5 text-sm font-medium text-zinc-400 hover:bg-zinc-800 disabled:opacity-50"
|
||||
>
|
||||
Test Connection
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="flex-1 rounded-xl bg-violet-600 px-5 py-2.5 text-sm font-bold text-white hover:bg-violet-700 disabled:opacity-50"
|
||||
>
|
||||
{saving ? "Saving..." : "Save Provider Settings"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { getAbandonedCarts, manuallyCloseAbandonedCart, resendAbandonedCartEmail, type AbandonedCart } from "@/actions/email-automation/abandoned-cart";
|
||||
|
||||
type Props = { brandId: string };
|
||||
|
||||
const STATUS_LABELS: Record<string, { en: string; es?: string; color: string }> = {
|
||||
active: { en: "Active", color: "bg-blue-900/40 text-blue-400 border border-blue-800" },
|
||||
recovered: { en: "Recovered", color: "bg-emerald-900/40 text-emerald-400 border border-emerald-800" },
|
||||
expired: { en: "Expired", color: "bg-zinc-800 text-zinc-400 border border-zinc-700" },
|
||||
manually_closed: { en: "Manually Closed", color: "bg-amber-900/40 text-amber-400 border border-amber-800" },
|
||||
};
|
||||
|
||||
const STEP_LABELS: Record<number, string> = {
|
||||
0: "Detected",
|
||||
1: "Email 1 (1h)",
|
||||
2: "Email 2 (24h)",
|
||||
3: "Email 3 (48h)",
|
||||
};
|
||||
|
||||
function CartItemRow({ item }: { item: { name: string; quantity: number; unit_price: number } }) {
|
||||
return (
|
||||
<tr className="border-t border-zinc-800/60">
|
||||
<td className="px-4 py-2 text-sm text-zinc-200">{item.name}</td>
|
||||
<td className="px-4 py-2 text-sm text-zinc-400 text-right">{item.quantity}</td>
|
||||
<td className="px-4 py-2 text-sm text-zinc-400 text-right">${Number(item.unit_price).toFixed(2)}</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AbandonedCartDashboard({ brandId }: Props) {
|
||||
const [carts, setCarts] = useState<AbandonedCart[]>([]);
|
||||
const [stats, setStats] = useState({ total: 0, recovered: 0, active: 0, expired: 0 });
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [filter, setFilter] = useState<"all" | "active" | "recovered">("all");
|
||||
const [page, setPage] = useState(0);
|
||||
const PAGE_SIZE = 25;
|
||||
const [selectedCart, setSelectedCart] = useState<AbandonedCart | null>(null);
|
||||
const [closing, setClosing] = useState(false);
|
||||
const [resending, setResending] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
const result = await getAbandonedCarts(brandId);
|
||||
if (result.success) {
|
||||
setCarts(result.carts);
|
||||
setStats(result.stats);
|
||||
}
|
||||
setLoading(false);
|
||||
}, [brandId]);
|
||||
|
||||
const handleClose = async (cartId: string) => {
|
||||
setClosing(true);
|
||||
await manuallyCloseAbandonedCart(cartId, brandId);
|
||||
await load();
|
||||
setClosing(false);
|
||||
};
|
||||
|
||||
useEffect(() => { setPage(0); }, [filter]);
|
||||
|
||||
const filteredCarts = carts.filter(c => {
|
||||
if (filter === "active") return c.status === "active";
|
||||
if (filter === "recovered") return c.status === "recovered";
|
||||
return true;
|
||||
});
|
||||
|
||||
const paginatedCarts = filteredCarts.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
|
||||
const totalPages = Math.ceil(filteredCarts.length / PAGE_SIZE);
|
||||
|
||||
const handleResend = async (cartId: string) => {
|
||||
setResending(cartId);
|
||||
await resendAbandonedCartEmail(cartId, brandId);
|
||||
await load();
|
||||
setResending(null);
|
||||
};
|
||||
|
||||
const cart = selectedCart;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Stats row */}
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{[
|
||||
{ label: "Total", value: stats.total, color: "text-zinc-100" },
|
||||
{ label: "Active", value: stats.active, color: "text-blue-400" },
|
||||
{ label: "Recovered", value: stats.recovered, color: "text-emerald-400" },
|
||||
{ label: "Expired", value: stats.expired, color: "text-zinc-400" },
|
||||
].map(s => (
|
||||
<div key={s.label} className="bg-zinc-900 border border-zinc-800 rounded-2xl p-4 text-center">
|
||||
<p className={`text-2xl font-black ${s.color}`}>{s.value}</p>
|
||||
<p className="text-xs text-zinc-500 uppercase tracking-widest mt-1">{s.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Filter + refresh + pagination */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={load} disabled={loading}
|
||||
className="text-xs font-semibold text-violet-400 hover:text-violet-300 transition-colors">
|
||||
{loading ? "Loading..." : "↻ Refresh"}
|
||||
</button>
|
||||
<div className="flex gap-2 ml-auto">
|
||||
{(["all", "active", "recovered"] as const).map(f => (
|
||||
<button key={f} onClick={() => setFilter(f)}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-semibold transition-all ${filter === f ? "bg-violet-600 text-white" : "bg-zinc-800 text-zinc-400 hover:text-zinc-200"}`}>
|
||||
{f.charAt(0).toUpperCase() + f.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center gap-1 ml-2">
|
||||
<button onClick={() => setPage(p => Math.max(0, p - 1))} disabled={page === 0}
|
||||
className="flex h-7 w-7 items-center justify-center rounded border border-zinc-700 text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800 disabled:opacity-30 disabled:cursor-not-allowed transition-colors">
|
||||
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" /></svg>
|
||||
</button>
|
||||
<span className="text-xs text-zinc-500 px-1">{page + 1}/{totalPages}</span>
|
||||
<button onClick={() => setPage(p => Math.min(totalPages - 1, p + 1))} disabled={page >= totalPages - 1}
|
||||
className="flex h-7 w-7 items-center justify-center rounded border border-zinc-700 text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800 disabled:opacity-30 disabled:cursor-not-allowed transition-colors">
|
||||
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Carts table */}
|
||||
{filteredCarts.length === 0 ? (
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-2xl py-16 text-center">
|
||||
<p className="text-zinc-600 text-sm">No abandoned carts{filter !== "all" ? ` (${filter})` : ""}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-2xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-xs text-zinc-500 uppercase tracking-widest border-b border-zinc-800">
|
||||
<th className="text-left px-5 py-3 font-medium">Contact</th>
|
||||
<th className="text-left px-5 py-3 font-medium">Items</th>
|
||||
<th className="text-left px-5 py-3 font-medium">Total</th>
|
||||
<th className="text-left px-5 py-3 font-medium">Step</th>
|
||||
<th className="text-left px-5 py-3 font-medium">Status</th>
|
||||
<th className="text-left px-5 py-3 font-medium">Detected</th>
|
||||
<th className="text-right px-5 py-3 font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{paginatedCarts.map(c => {
|
||||
const meta = STATUS_LABELS[c.status] ?? STATUS_LABELS.active;
|
||||
return (
|
||||
<tr key={c.id} className="border-t border-zinc-800/60 hover:bg-zinc-800/30 transition-colors">
|
||||
<td className="px-5 py-3.5">
|
||||
<div>
|
||||
<p className="text-zinc-200 font-medium text-sm">{c.contact_name ?? "—"}</p>
|
||||
<p className="text-zinc-500 text-xs">{c.contact_email}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-3.5 text-zinc-400 text-xs">{c.cart_snapshot.item_count} items</td>
|
||||
<td className="px-5 py-3.5 text-zinc-200 font-semibold text-sm">${Number(c.cart_snapshot.subtotal).toFixed(2)}</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<span className="text-xs text-zinc-500">{STEP_LABELS[c.sequence_step] ?? c.sequence_step}</span>
|
||||
</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold ${meta.color}`}>
|
||||
{meta.en}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-3.5 text-zinc-500 text-xs">{new Date(c.created_at).toLocaleDateString()}</td>
|
||||
<td className="px-5 py-3.5 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button onClick={() => setSelectedCart(c)}
|
||||
className="text-xs text-zinc-400 hover:text-zinc-200 px-2 py-1 rounded-lg hover:bg-zinc-800 transition-all">View</button>
|
||||
{c.status === "active" && (
|
||||
<button onClick={() => handleClose(c.id)} disabled={closing}
|
||||
className="text-xs text-amber-400 hover:text-amber-300 px-2 py-1 rounded-lg hover:bg-amber-900/20 transition-all">Close</button>
|
||||
)}
|
||||
{c.status === "active" && (
|
||||
<button onClick={() => handleResend(c.id)} disabled={resending === c.id}
|
||||
className="text-xs text-violet-400 hover:text-violet-300 px-2 py-1 rounded-lg hover:bg-violet-900/20 transition-all">
|
||||
{resending === c.id ? "..." : "Resend"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cart detail modal */}
|
||||
{cart && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
|
||||
onClick={() => setSelectedCart(null)}>
|
||||
<div className="bg-zinc-900 border border-zinc-700 rounded-2xl w-full max-w-lg shadow-2xl"
|
||||
onClick={e => e.stopPropagation()}>
|
||||
<div className="px-6 py-4 border-b border-zinc-800 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-zinc-100">Abandoned Cart</h3>
|
||||
<p className="text-xs text-zinc-500">{cart.contact_email}</p>
|
||||
</div>
|
||||
<button onClick={() => setSelectedCart(null)} className="text-zinc-500 hover:text-zinc-200 text-xl leading-none">×</button>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="flex items-start gap-3 bg-zinc-800/50 rounded-xl p-3">
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold ${STATUS_LABELS[cart.status]?.color ?? ""}`}>
|
||||
{STATUS_LABELS[cart.status]?.en ?? cart.status}
|
||||
</span>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-zinc-200 font-medium">{cart.contact_name ?? "—"}</p>
|
||||
<p className="text-xs text-zinc-500">{cart.contact_email}</p>
|
||||
<p className="text-xs text-zinc-600 mt-1">Step: {STEP_LABELS[cart.sequence_step] ?? cart.sequence_step}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-zinc-500 mb-2">Cart Items</p>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-xs text-zinc-600 border-b border-zinc-800">
|
||||
<th className="text-left pb-2 font-medium">Item</th>
|
||||
<th className="text-right pb-2 font-medium">Qty</th>
|
||||
<th className="text-right pb-2 font-medium">Price</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{cart.cart_snapshot.items.map((item, i) => (
|
||||
<CartItemRow key={i} item={item} />
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="border-t border-zinc-700">
|
||||
<td className="pt-2 text-zinc-200 font-semibold" colSpan={2}>Total</td>
|
||||
<td className="pt-2 text-zinc-200 font-bold text-right">${Number(cart.cart_snapshot.subtotal).toFixed(2)}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{cart.last_email_sent_at && (
|
||||
<p className="text-xs text-zinc-500">Last email sent: {new Date(cart.last_email_sent_at).toLocaleString()}</p>
|
||||
)}
|
||||
{cart.next_email_at && (
|
||||
<p className="text-xs text-zinc-500">Next email: {new Date(cart.next_email_at).toLocaleString()}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import Link from "next/link";
|
||||
|
||||
type AccessDeniedProps = {
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export default function AdminAccessDenied({
|
||||
message = "You do not have permission to view this page.",
|
||||
}: AccessDeniedProps) {
|
||||
return (
|
||||
<main className="min-h-screen flex items-center justify-center px-6 py-12 bg-stone-100">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="card p-8 text-center">
|
||||
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-2xl bg-red-50 border border-red-200">
|
||||
<svg
|
||||
className="h-7 w-7 text-red-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="mt-6 text-xl font-semibold text-stone-950">
|
||||
Access Denied
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-stone-500">{message}</p>
|
||||
<Link
|
||||
href="/admin"
|
||||
className="mt-6 inline-flex items-center gap-2 rounded-xl bg-emerald-600 hover:bg-emerald-500 px-5 py-2.5 text-sm font-medium text-white transition-all shadow-sm"
|
||||
>
|
||||
Back to Admin
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
|
||||
type AdminHeaderProps = {
|
||||
userRole?: string | null;
|
||||
canManageUsers?: boolean;
|
||||
routeTraceEnabled?: boolean;
|
||||
};
|
||||
|
||||
type NavLink = {
|
||||
href: string;
|
||||
label: string;
|
||||
external?: boolean;
|
||||
};
|
||||
|
||||
type SettingsItem = {
|
||||
href: string;
|
||||
label: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
const SETTINGS_ITEMS: SettingsItem[] = [
|
||||
{ href: "/admin/settings", label: "General", description: "Brand name, logo, timezone" },
|
||||
{ href: "/admin/settings/brand", label: "Brand", description: "Brand profile and preferences" },
|
||||
{ href: "/admin/users", label: "Users & Permissions", description: "Team members and access roles" },
|
||||
{ href: "/admin/settings/integrations", label: "Integrations", description: "Stripe, Square, Resend, Twilio" },
|
||||
{ href: "/admin/settings/apps", label: "Add-ons", description: "Enable and manage feature add-ons" },
|
||||
{ href: "/admin/settings/billing", label: "Billing & Plans", description: "Subscription, invoices, usage" },
|
||||
{ href: "/admin/settings/payments", label: "Payments", description: "Stripe, Square, payment methods" },
|
||||
{ href: "/admin/communications/abandoned-carts", label: "Abandoned Cart Recovery", description: "3-email recovery sequence, recovered carts" },
|
||||
{ href: "/admin/communications/welcome-sequence", label: "Welcome Sequence", description: "4-email onboarding for new subscribers" },
|
||||
];
|
||||
|
||||
const BASE_NAV_LINKS: NavLink[] = [
|
||||
{ href: "/admin/orders", label: "Orders" },
|
||||
{ href: "/admin/stops", label: "Stops & Routes" },
|
||||
{ href: "/admin/products", label: "Products" },
|
||||
{ href: "/admin/time-tracking", label: "Time Tracking" },
|
||||
{ href: "/admin/communications", label: "Harvest Reach" },
|
||||
];
|
||||
|
||||
const EMPLOYEE_NAV_LINKS: NavLink[] = [
|
||||
{ href: "/admin/wholesale", label: "Wholesale" },
|
||||
{ href: "/admin/pickup", label: "Pickup" },
|
||||
{ href: "/wholesale/employee", label: "Pickup Portal", external: true },
|
||||
];
|
||||
|
||||
export default function AdminHeader({ userRole, canManageUsers, routeTraceEnabled }: AdminHeaderProps) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const isStoreEmployee = userRole === "store_employee";
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const settingsRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close dropdown on outside click
|
||||
useEffect(() => {
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (settingsRef.current && !settingsRef.current.contains(e.target as Node)) {
|
||||
setSettingsOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
}, []);
|
||||
|
||||
// Close dropdown on route change
|
||||
useEffect(() => {
|
||||
setSettingsOpen(false);
|
||||
}, [pathname]);
|
||||
|
||||
const moduleLinks: NavLink[] = routeTraceEnabled
|
||||
? [{ href: "/admin/route-trace", label: "Route Trace" }]
|
||||
: [];
|
||||
|
||||
const fullNavLinks = BASE_NAV_LINKS.concat(
|
||||
userRole === "platform_admin" ? [{ href: "/admin/command-center", label: "Command Center" }] : []
|
||||
).concat(moduleLinks);
|
||||
|
||||
const navLinks = isStoreEmployee ? EMPLOYEE_NAV_LINKS : fullNavLinks;
|
||||
const homeHref = isStoreEmployee ? "/admin/wholesale" : "/admin";
|
||||
const homeLabel = isStoreEmployee ? "Pickup" : "Admin";
|
||||
|
||||
async function handleLogout() {
|
||||
document.cookie = "dev_session=;path=/;max-age=0";
|
||||
await supabase.auth.signOut();
|
||||
router.push("/login");
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
const roleLabel = userRole === "platform_admin" ? "Platform Admin"
|
||||
: userRole === "brand_admin" ? "Brand Admin"
|
||||
: userRole === "store_employee" ? "Store Employee"
|
||||
: null;
|
||||
|
||||
const isActive = (href: string) => {
|
||||
if (href === "/admin") return pathname === "/admin";
|
||||
if (href === "/admin/settings") {
|
||||
return pathname === "/admin/settings" || pathname.startsWith("/admin/settings/");
|
||||
}
|
||||
return pathname.startsWith(href);
|
||||
};
|
||||
|
||||
const isSettingsActive =
|
||||
pathname.startsWith("/admin/settings") ||
|
||||
pathname === "/admin/settings" ||
|
||||
pathname.startsWith("/admin/communications/abandoned-carts") ||
|
||||
pathname.startsWith("/admin/communications/welcome-sequence");
|
||||
|
||||
return (
|
||||
<header className="border-b border-white/5 bg-gradient-to-b from-zinc-900/80 to-transparent backdrop-blur-xl">
|
||||
<div className="mx-auto flex max-w-7xl items-center justify-between px-6 py-0">
|
||||
<div className="flex items-center h-16">
|
||||
{/* Logo mark */}
|
||||
<Link
|
||||
href={homeHref}
|
||||
className="flex items-center gap-3 mr-8 text-white hover:text-emerald-400 transition-colors"
|
||||
>
|
||||
<div className="relative">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-emerald-500 to-emerald-600 shadow-lg shadow-emerald-500/30">
|
||||
<svg className="h-4.5 w-4.5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l.707-.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-sm font-semibold tracking-tight">{homeLabel}</span>
|
||||
</Link>
|
||||
|
||||
<nav className="flex h-full items-center gap-1">
|
||||
{navLinks.map((link) => {
|
||||
const active = isActive(link.href);
|
||||
return (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
{...(link.external ? { target: "_blank", rel: "noopener noreferrer" } : {})}
|
||||
className={[
|
||||
"px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200",
|
||||
active
|
||||
? "bg-emerald-500/10 text-emerald-400 border border-emerald-500/20"
|
||||
: "text-zinc-400 hover:text-white hover:bg-white/5 border border-transparent",
|
||||
].join(" ")}
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Settings dropdown */}
|
||||
<div className="relative ml-2" ref={settingsRef}>
|
||||
<button
|
||||
onClick={() => setSettingsOpen(v => !v)}
|
||||
className={[
|
||||
"px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200 flex items-center gap-2",
|
||||
isSettingsActive
|
||||
? "bg-emerald-500/10 text-emerald-400 border border-emerald-500/20"
|
||||
: "text-zinc-400 hover:text-white hover:bg-white/5 border border-transparent",
|
||||
].join(" ")}
|
||||
>
|
||||
Settings
|
||||
<svg className={`w-3.5 h-3.5 transition-transform ${settingsOpen ? "rotate-180" : ""}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{settingsOpen && (
|
||||
<div className="absolute top-full left-0 mt-2 w-80 rounded-2xl glass-strong shadow-2xl z-50 overflow-hidden border border-white/10">
|
||||
<div className="p-2">
|
||||
{SETTINGS_ITEMS.map((item) => {
|
||||
const active = pathname === item.href || (item.href !== "/admin/settings" && pathname.startsWith(item.href));
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={[
|
||||
"flex items-start gap-3 rounded-xl px-4 py-3 transition-all duration-200",
|
||||
active
|
||||
? "bg-emerald-500/10 border border-emerald-500/20"
|
||||
: "hover:bg-white/5 border border-transparent",
|
||||
].join(" ")}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={`text-sm font-medium ${active ? "text-emerald-400" : "text-white"}`}>
|
||||
{item.label}
|
||||
</p>
|
||||
<p className="text-xs text-zinc-500 mt-0.5 leading-snug">{item.description}</p>
|
||||
</div>
|
||||
{active && (
|
||||
<span className="mt-0.5 flex-shrink-0 w-2 h-2 rounded-full bg-emerald-500 shadow-lg shadow-emerald-500/50" />
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
<div className="flex items-center gap-6">
|
||||
{roleLabel && (
|
||||
<div className="px-3 py-1.5 rounded-lg bg-white/5 border border-white/5">
|
||||
<span className="text-xs font-medium text-zinc-400">{roleLabel}</span>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="text-sm font-medium text-zinc-500 hover:text-white transition-colors px-3 py-1.5 rounded-lg hover:bg-white/5"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { markPickupComplete } from "@/actions/pickup";
|
||||
|
||||
type OrderItem = {
|
||||
id: string;
|
||||
product_id: string;
|
||||
quantity: number;
|
||||
price: number;
|
||||
products: { name: string } | null;
|
||||
};
|
||||
|
||||
type Order = {
|
||||
id: string;
|
||||
customer_name: string;
|
||||
customer_email: string | null;
|
||||
customer_phone: string | null;
|
||||
stop_id: string | null;
|
||||
status: string;
|
||||
subtotal: number;
|
||||
pickup_complete: boolean;
|
||||
pickup_completed_at: string | null;
|
||||
pickup_completed_by: string | null;
|
||||
created_at: string;
|
||||
payment_processor: string | null;
|
||||
stops: {
|
||||
id?: string;
|
||||
city: string;
|
||||
state: string;
|
||||
date: string;
|
||||
brand_id?: string;
|
||||
} | null;
|
||||
order_items?: OrderItem[];
|
||||
};
|
||||
|
||||
type Stop = {
|
||||
id: string;
|
||||
city: string;
|
||||
state: string;
|
||||
date: string;
|
||||
brand_id?: string;
|
||||
};
|
||||
|
||||
type AdminOrdersPanelProps = {
|
||||
initialOrders: Order[];
|
||||
initialStops: Stop[];
|
||||
brandId: string | null;
|
||||
};
|
||||
|
||||
function shortId(id: string) {
|
||||
return id.slice(0, 8).toUpperCase();
|
||||
}
|
||||
|
||||
function formatDate(iso: string | undefined) {
|
||||
if (!iso) return "—";
|
||||
return new Date(iso).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
export { formatDate };
|
||||
|
||||
export default function AdminOrdersPanel({
|
||||
initialOrders,
|
||||
initialStops,
|
||||
brandId,
|
||||
}: AdminOrdersPanelProps) {
|
||||
const [orders, setOrders] = useState<Order[]>(initialOrders);
|
||||
const [stops] = useState<Stop[]>(initialStops);
|
||||
const [search, setSearch] = useState("");
|
||||
const [stopFilter, setStopFilter] = useState("");
|
||||
const [pickingUp, setPickingUp] = useState<string | null>(null);
|
||||
const [squareFilter, setSquareFilter] = useState<"all" | "square" | "other">("all");
|
||||
const [statusFilter, setStatusFilter] = useState<"all" | "pending" | "picked_up">("all");
|
||||
const [page, setPage] = useState(0);
|
||||
const [pickupToast, setPickupToast] = useState<string | null>(null);
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
async function handleMarkPickup(orderId: string) {
|
||||
setPickingUp(orderId);
|
||||
const result = await markPickupComplete(orderId, brandId);
|
||||
if (result.success) {
|
||||
setPickupToast(orderId);
|
||||
setTimeout(() => setPickupToast(null), 3000);
|
||||
setOrders((prev) =>
|
||||
prev.map((o) =>
|
||||
o.id === orderId
|
||||
? {
|
||||
...o,
|
||||
pickup_complete: true,
|
||||
pickup_completed_at: result.pickup_completed_at,
|
||||
pickup_completed_by: result.pickup_completed_by,
|
||||
}
|
||||
: o
|
||||
)
|
||||
);
|
||||
}
|
||||
setPickingUp(null);
|
||||
}
|
||||
|
||||
const filtered = orders.filter((o) => {
|
||||
const matchesSearch =
|
||||
!search ||
|
||||
o.customer_name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
(o.customer_phone ?? "").includes(search) ||
|
||||
o.id.includes(search.toUpperCase().slice(0, 8));
|
||||
|
||||
const matchesStop = !stopFilter || o.stop_id === stopFilter;
|
||||
|
||||
const matchesSquare =
|
||||
squareFilter === "all" ||
|
||||
(squareFilter === "square" && o.payment_processor === "square") ||
|
||||
(squareFilter === "other" && o.payment_processor !== "square");
|
||||
|
||||
const matchesStatus =
|
||||
statusFilter === "all" ||
|
||||
(statusFilter === "pending" && !o.pickup_complete) ||
|
||||
(statusFilter === "picked_up" && o.pickup_complete);
|
||||
|
||||
return matchesSearch && matchesStop && matchesSquare && matchesStatus;
|
||||
});
|
||||
|
||||
const totalFiltered = filtered.length;
|
||||
const paginated = filtered.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
|
||||
const totalPages = Math.ceil(totalFiltered / PAGE_SIZE);
|
||||
|
||||
const pendingCount = filtered.filter((o) => !o.pickup_complete).length;
|
||||
const pickedUpCount = filtered.filter((o) => o.pickup_complete).length;
|
||||
|
||||
return (
|
||||
<div className="bg-transparent">
|
||||
{/* Header */}
|
||||
<div className="border-b border-stone-200 bg-white px-6 py-5">
|
||||
<div className="mx-auto max-w-7xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-stone-950 tracking-tight">Orders</h1>
|
||||
<p className="mt-0.5 text-sm text-stone-500">
|
||||
{pendingCount} pending
|
||||
{pickedUpCount > 0 && ` · ${pickedUpCount} picked up`}
|
||||
{brandId && (
|
||||
<span className="ml-2 text-xs text-stone-400">(brand-scoped)</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto max-w-7xl px-6 py-4">
|
||||
{/* Status filter + pagination */}
|
||||
<div className="mb-4 flex items-center justify-between gap-3 flex-wrap">
|
||||
<div className="flex gap-1 rounded-lg border border-stone-200 bg-white p-1">
|
||||
{(["all", "pending", "picked_up"] as const).map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => { setStatusFilter(f); setPage(0); }}
|
||||
className={`rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
|
||||
statusFilter === f
|
||||
? "bg-emerald-100 text-emerald-700 border border-emerald-200"
|
||||
: "text-stone-500 hover:text-stone-700 hover:bg-stone-50"
|
||||
}`}
|
||||
>
|
||||
{f === "all" ? "All" : f === "pending" ? "Pending" : "Picked Up"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||
disabled={page === 0}
|
||||
className="flex h-7 w-7 items-center justify-center rounded border border-stone-200 text-stone-500 hover:border-stone-300 hover:text-stone-700 hover:bg-stone-50 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
aria-label="Previous page"
|
||||
>
|
||||
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<span className="px-2 text-xs text-stone-500">{page + 1} / {totalPages}</span>
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
|
||||
disabled={page >= totalPages - 1}
|
||||
className="flex h-7 w-7 items-center justify-center rounded border border-stone-200 text-stone-500 hover:border-stone-300 hover:text-stone-700 hover:bg-stone-50 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
aria-label="Next page"
|
||||
>
|
||||
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search + Stop + Square filter */}
|
||||
<div className="mb-4 flex gap-3 flex-wrap">
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search name, phone, order #..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="flex-1 min-w-48 rounded-lg border border-stone-200 bg-white px-3 py-2 text-sm text-stone-900 outline-none focus:border-emerald-500 placeholder:text-stone-400 font-mono transition-colors"
|
||||
/>
|
||||
<select
|
||||
value={stopFilter}
|
||||
onChange={(e) => setStopFilter(e.target.value)}
|
||||
className="rounded-lg border border-stone-200 bg-white px-3 py-2 text-sm text-stone-700 outline-none focus:border-emerald-500 transition-colors"
|
||||
>
|
||||
<option value="">All Stops</option>
|
||||
{stops.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.city}, {s.state} · {formatDate(s.date)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="flex gap-1 rounded-lg border border-stone-200 bg-white p-1">
|
||||
{(["all", "square", "other"] as const).map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setSquareFilter(f)}
|
||||
className={`rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
|
||||
squareFilter === f
|
||||
? "bg-emerald-100 text-emerald-700 border border-emerald-200"
|
||||
: "text-stone-500 hover:text-stone-700 hover:bg-stone-50"
|
||||
}`}
|
||||
>
|
||||
{f === "all" ? "All" : f === "square" ? "Square" : "Other"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
{paginated.length === 0 ? (
|
||||
<div className="rounded-lg bg-white border border-stone-200 py-12 text-center text-sm text-stone-500 shadow-sm">
|
||||
No orders found
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-lg bg-white border border-stone-200 shadow-sm">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-stone-200 bg-stone-50 text-left">
|
||||
<th className="px-4 py-3 font-semibold text-stone-500">Order</th>
|
||||
<th className="px-4 py-3 font-semibold text-stone-500">Customer</th>
|
||||
<th className="px-4 py-3 font-semibold text-stone-500 hidden md:table-cell">Stop</th>
|
||||
<th className="px-4 py-3 font-semibold text-stone-500 hidden lg:table-cell">Date</th>
|
||||
<th className="px-4 py-3 font-semibold text-stone-500 text-center">Items</th>
|
||||
<th className="px-4 py-3 font-semibold text-stone-500 text-right">Total</th>
|
||||
<th className="px-4 py-3 font-semibold text-stone-500 text-center">Status</th>
|
||||
<th className="px-4 py-3 font-semibold text-stone-500 text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{paginated.map((order) => (
|
||||
<OrderRow
|
||||
key={order.id}
|
||||
order={order}
|
||||
onMarkPickup={handleMarkPickup}
|
||||
pickingUp={pickingUp}
|
||||
shortId={shortId(order.id)}
|
||||
showToast={pickupToast === order.id}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type OrderRowProps = {
|
||||
order: Order;
|
||||
onMarkPickup: (orderId: string) => void;
|
||||
pickingUp: string | null;
|
||||
shortId: string;
|
||||
showToast?: boolean;
|
||||
};
|
||||
|
||||
function OrderRow({ order, onMarkPickup, pickingUp, shortId, showToast }: OrderRowProps) {
|
||||
const itemCount = order.order_items?.length ?? 0;
|
||||
|
||||
return (
|
||||
<tr className="border-b border-stone-100 last:border-0 hover:bg-stone-50 transition-colors">
|
||||
{/* Order ID */}
|
||||
<td className="px-4 py-3">
|
||||
<span className="font-mono text-xs text-stone-500">{shortId}</span>
|
||||
</td>
|
||||
|
||||
{/* Customer */}
|
||||
<td className="px-4 py-3">
|
||||
<div className="font-medium text-stone-900">{order.customer_name}</div>
|
||||
{order.customer_phone && (
|
||||
<div className="font-mono text-xs text-stone-400">{order.customer_phone}</div>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* Stop */}
|
||||
<td className="px-4 py-3 hidden md:table-cell">
|
||||
{order.stops ? (
|
||||
<span className="text-stone-600">{order.stops.city}, {order.stops.state}</span>
|
||||
) : (
|
||||
<span className="text-stone-300">—</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* Date */}
|
||||
<td className="px-4 py-3 hidden lg:table-cell">
|
||||
<span className="font-mono text-stone-500">{formatDate(order.created_at)}</span>
|
||||
</td>
|
||||
|
||||
{/* Items */}
|
||||
<td className="px-4 py-3 text-center">
|
||||
<span className="font-mono text-stone-600">{itemCount}</span>
|
||||
</td>
|
||||
|
||||
{/* Total */}
|
||||
<td className="px-4 py-3 text-right">
|
||||
<span className="font-mono font-semibold text-stone-900">
|
||||
${Number(order.subtotal).toFixed(2)}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* Status */}
|
||||
<td className="px-4 py-3 text-center">
|
||||
<span
|
||||
className={`inline-block rounded-full px-2 py-0.5 text-xs font-semibold ${
|
||||
order.pickup_complete
|
||||
? "bg-emerald-100 text-emerald-700 border border-emerald-200"
|
||||
: "bg-amber-100 text-amber-700 border border-amber-200"
|
||||
}`}
|
||||
>
|
||||
{order.pickup_complete ? "Picked Up" : "Pending"}
|
||||
</span>
|
||||
{order.payment_processor === "square" && (
|
||||
<span className="ml-1.5 inline-block rounded-full bg-purple-100 border border-purple-200 px-1.5 py-0.5 text-xs font-semibold text-purple-700">
|
||||
Square
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* Actions */}
|
||||
<td className="px-4 py-3 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<a
|
||||
href={`/admin/orders/${order.id}`}
|
||||
className="rounded-lg px-3 py-1.5 text-xs font-medium text-stone-600 hover:text-stone-900 hover:bg-stone-100 transition-colors"
|
||||
>
|
||||
View / Edit
|
||||
</a>
|
||||
{!order.pickup_complete && (
|
||||
<button
|
||||
onClick={() => onMarkPickup(order.id)}
|
||||
disabled={pickingUp === order.id}
|
||||
className="rounded-lg bg-emerald-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-emerald-500 active:bg-emerald-700 disabled:opacity-50 transition-colors shadow-sm"
|
||||
>
|
||||
{pickingUp === order.id ? "..." : "Pick Up"}
|
||||
</button>
|
||||
)}
|
||||
{showToast && (
|
||||
<div className="fixed bottom-6 right-6 z-50 rounded-lg border border-emerald-200 bg-emerald-50 px-4 py-2.5 text-xs font-semibold text-emerald-700 shadow-lg">
|
||||
Done
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
|
||||
// Warm earth-tone dark sidebar design
|
||||
// Colors: stone-900 bg, stone-300 text, emerald accent
|
||||
|
||||
const TOP_LINKS = [
|
||||
{ href: "/admin", label: "Dashboard", icon: "grid" },
|
||||
{ href: "/admin/orders", label: "Orders", icon: "shopping-cart" },
|
||||
{ href: "/admin/stops", label: "Stops & Routes", icon: "map-pin" },
|
||||
{ href: "/admin/products", label: "Products", icon: "package" },
|
||||
{ href: "/admin/route-trace", label: "Route Trace", icon: "clipboard" },
|
||||
{ href: "/admin/time-tracking", label: "Time Tracking", icon: "clock" },
|
||||
{ href: "/admin/communications", label: "Communications", icon: "mail" },
|
||||
];
|
||||
|
||||
const SETTINGS_SUB_LINKS = [
|
||||
{ href: "/admin/settings", label: "General" },
|
||||
{ href: "/admin/settings#workers", label: "Workers & PINs" },
|
||||
{ href: "/admin/settings#tasks", label: "Tasks" },
|
||||
{ href: "/admin/settings#users", label: "Users & Permissions" },
|
||||
{ href: "/admin/settings#integrations", label: "Integrations" },
|
||||
];
|
||||
|
||||
function GridIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className ?? "w-4 h-4"} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25a2.25 2.25 0 01-2.25 2.25H15.75a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H15.75a2.25 2.25 0 01-2.25-2.25v-2.25z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function CartIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className ?? "w-4 h-4"} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 10.5V6a3.75 3.75 0 10-7.5 0v4.5m11.356-1.993l1.263 12c.07.665-.45 1.243-1.119 1.243H4.25a1.125 1.125 0 01-1.12-1.243l1.264-12A1.125 1.125 0 015.513 7.5h12.974c.576 0 1.059.435 1.119 1.007zM12.94 18.55l.276-.276a.75.75 0 011.06 0l.27.27a.75.75 0 010 1.06l-.27.27a.75.75 0 01-1.06 0l-.276-.276a.75.75 0 010-1.06z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function MapPinIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className ?? "w-4 h-4"} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function PackageIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className ?? "w-4 h-4"} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5M10 11.25h4M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ClipboardIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className ?? "w-4 h-4"} 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>
|
||||
);
|
||||
}
|
||||
|
||||
function ClockIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className ?? "w-4 h-4"} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function MailIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className ?? "w-4 h-4"} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 01-3-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsCogIcon({ open }: { open: boolean }) {
|
||||
return (
|
||||
<svg className={`w-4 h-4 transition-transform ${open ? "rotate-45" : ""}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09.542.56.94 1.11.94h2.64c.55 0 1.02-.398 1.11-.94l.213-1.999c.018-.158.04-.315.062-.472a.563.563 0 00-.122-.519l-.79-2.758A.562.562 0 0014.56 0H9.44a.563.563 0 00-.424.264l-.79 2.758a.563.563 0 00-.122.519c.022.157.044.314.062.472l.213 1.999zM12 15.75a3.75 3.75 0 100-7.5 3.75 3.75 0 000 7.5z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ChevronIcon({ open }: { open: boolean }) {
|
||||
return (
|
||||
<svg className={`w-3.5 h-3.5 transition-transform ${open ? "rotate-180" : ""}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function HamburgerIcon() {
|
||||
return (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const ICON_MAP: Record<string, React.ReactNode> = {
|
||||
grid: <GridIcon />,
|
||||
"shopping-cart": <CartIcon />,
|
||||
"map-pin": <MapPinIcon />,
|
||||
package: <PackageIcon />,
|
||||
clipboard: <ClipboardIcon />,
|
||||
clock: <ClockIcon />,
|
||||
mail: <MailIcon />,
|
||||
};
|
||||
|
||||
type SidebarProps = {
|
||||
userRole?: string | null;
|
||||
};
|
||||
|
||||
export default function AdminSidebar({ userRole }: SidebarProps) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
|
||||
const roleLabel = userRole === "platform_admin" ? "Platform Admin"
|
||||
: userRole === "brand_admin" ? "Brand Admin"
|
||||
: userRole === "store_employee" ? "Store Employee"
|
||||
: null;
|
||||
|
||||
const isActive = (href: string) => {
|
||||
if (href === "/admin") return pathname === "/admin";
|
||||
if (href === "/admin/settings") return pathname === "/admin/settings" || pathname.startsWith("/admin/settings");
|
||||
return pathname.startsWith(href);
|
||||
};
|
||||
|
||||
const isSettingsPage = pathname.startsWith("/admin/settings");
|
||||
|
||||
async function handleLogout() {
|
||||
document.cookie = "dev_session=;path=/;max-age=0";
|
||||
await supabase.auth.signOut();
|
||||
setMobileOpen(false);
|
||||
router.push("/login");
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile hamburger button */}
|
||||
<button
|
||||
onClick={() => setMobileOpen(true)}
|
||||
className="fixed top-4 left-4 z-50 lg:hidden"
|
||||
aria-label="Open sidebar"
|
||||
>
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-white shadow-md border border-stone-200">
|
||||
<HamburgerIcon />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Mobile overlay backdrop */}
|
||||
{mobileOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/30 backdrop-blur-sm z-40 lg:hidden"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sidebar panel - Warm earth-tone dark */}
|
||||
<aside className={[
|
||||
"fixed top-0 left-0 h-full w-60 z-50",
|
||||
"bg-stone-900 border-r border-stone-800",
|
||||
"flex flex-col transition-transform duration-300 lg:translate-x-0",
|
||||
mobileOpen ? "translate-x-0" : "-translate-x-full"
|
||||
].join(" ")}>
|
||||
|
||||
{/* Logo row */}
|
||||
<div className="flex items-center h-16 px-5 border-b border-stone-800 flex-shrink-0">
|
||||
<Link
|
||||
href="/admin"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className="flex items-center gap-3 text-stone-100 hover:text-emerald-400 transition-colors"
|
||||
>
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-emerald-600 shadow-sm">
|
||||
<svg className="h-5 w-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm font-semibold tracking-tight">Admin</span>
|
||||
<a
|
||||
href="/"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="text-xs text-stone-500 hover:text-stone-300 transition-colors ml-2"
|
||||
>
|
||||
← Back to Site
|
||||
</a>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Nav links */}
|
||||
<nav className="flex-1 overflow-y-auto px-3 py-5 space-y-1">
|
||||
{TOP_LINKS.map((item) => {
|
||||
const active = isActive(item.href);
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className={[
|
||||
"group flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all duration-200",
|
||||
active
|
||||
? "bg-emerald-900/20 text-emerald-400 border-l-2 border-emerald-500"
|
||||
: "text-stone-300 hover:text-stone-100 hover:bg-stone-800/50 border-l-2 border-transparent",
|
||||
].join(" ")}
|
||||
>
|
||||
<span className={[
|
||||
"flex-shrink-0 transition-colors",
|
||||
active ? "text-emerald-400" : "text-stone-500 group-hover:text-stone-300"
|
||||
].join(" ")}>
|
||||
{ICON_MAP[item.icon]}
|
||||
</span>
|
||||
{item.label}
|
||||
{active && (
|
||||
<span className="ml-auto w-1.5 h-1.5 rounded-full bg-emerald-500" />
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Settings collapsible section */}
|
||||
<div className="pt-4 pb-1">
|
||||
<div className="h-px bg-stone-800 mb-4" />
|
||||
<button
|
||||
onClick={() => setSettingsOpen(v => !v)}
|
||||
className={[
|
||||
"w-full flex items-center justify-between px-3 py-2.5 rounded-xl text-sm font-medium transition-all duration-200",
|
||||
isSettingsPage
|
||||
? "bg-emerald-900/20 text-emerald-400 border-l-2 border-emerald-500"
|
||||
: "text-stone-300 hover:text-stone-100 hover:bg-stone-800/50 border-l-2 border-transparent",
|
||||
].join(" ")}
|
||||
>
|
||||
<span className="flex items-center gap-3">
|
||||
<span className={isSettingsPage ? "text-emerald-400" : "text-stone-500"}>
|
||||
<SettingsCogIcon open={settingsOpen} />
|
||||
</span>
|
||||
Settings
|
||||
</span>
|
||||
<ChevronIcon open={settingsOpen} />
|
||||
</button>
|
||||
|
||||
{settingsOpen && (
|
||||
<div className="mt-2 ml-2 space-y-0.5 border-l border-stone-700 pl-3">
|
||||
{SETTINGS_SUB_LINKS.map((item) => {
|
||||
const active = isActive(item.href);
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className={[
|
||||
"flex items-center px-3 py-2 rounded-lg text-sm transition-all duration-200",
|
||||
active
|
||||
? "bg-emerald-900/20 text-emerald-400"
|
||||
: "text-stone-400 hover:text-stone-200 hover:bg-stone-800/50",
|
||||
].join(" ")}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Bottom: role + sign out */}
|
||||
<div className="px-4 py-5 border-t border-stone-800 flex-shrink-0">
|
||||
{roleLabel && (
|
||||
<div className="px-3 py-2 mb-3 rounded-lg bg-stone-800 border border-stone-700">
|
||||
<p className="text-[10px] text-stone-500 font-medium uppercase tracking-widest mb-0.5">{roleLabel}</p>
|
||||
<p className="text-xs text-stone-400">Signed in</p>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full px-3 py-2 rounded-lg text-sm font-medium text-stone-400 hover:text-stone-200 hover:bg-stone-800 transition-all border border-transparent"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { ADDON_CATALOG, type BrandFeatureKey } from "@/lib/feature-flags";
|
||||
import { toggleBrandFeature } from "@/actions/settings/features";
|
||||
|
||||
type Props = {
|
||||
brandId: string;
|
||||
initialEnabledFeatures: Record<string, boolean>;
|
||||
};
|
||||
|
||||
export default function BrandFeatureCards({ brandId, initialEnabledFeatures }: Props) {
|
||||
const router = useRouter();
|
||||
const [enabledFeatures, setEnabledFeatures] = useState<Record<string, boolean>>(initialEnabledFeatures);
|
||||
const [toggling, setToggling] = useState<string | null>(null);
|
||||
const [toast, setToast] = useState<string | null>(null);
|
||||
|
||||
const showToast = (msg: string) => {
|
||||
setToast(msg);
|
||||
setTimeout(() => setToast(null), 3000);
|
||||
};
|
||||
|
||||
const handleToggle = async (key: string) => {
|
||||
const newEnabled = !enabledFeatures[key];
|
||||
setToggling(key);
|
||||
setEnabledFeatures((prev) => ({ ...prev, [key]: newEnabled }));
|
||||
const result = await toggleBrandFeature(brandId, key as BrandFeatureKey, newEnabled);
|
||||
if (result.success) {
|
||||
showToast(newEnabled ? `${ADDON_CATALOG[key as BrandFeatureKey].name} enabled` : `${ADDON_CATALOG[key as BrandFeatureKey].name} disabled`);
|
||||
router.refresh();
|
||||
} else {
|
||||
setEnabledFeatures((prev) => ({ ...prev, [key]: !newEnabled }));
|
||||
showToast(`Error: ${result.error}`);
|
||||
}
|
||||
setToggling(null);
|
||||
};
|
||||
|
||||
const keys = Object.keys(ADDON_CATALOG) as BrandFeatureKey[];
|
||||
|
||||
return (
|
||||
<>
|
||||
{toast && (
|
||||
<div className="fixed bottom-6 right-6 z-50 rounded-xl bg-stone-900 px-4 py-3 text-sm font-medium text-white shadow-lg">
|
||||
{toast}
|
||||
</div>
|
||||
)}
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
{keys.map((key) => {
|
||||
const addon = ADDON_CATALOG[key];
|
||||
const enabled = !!enabledFeatures[key];
|
||||
const busy = toggling === key;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className={`rounded-2xl p-6 shadow-black/20 ring-1 ${
|
||||
enabled
|
||||
? "bg-gradient-to-br from-white to-green-50 ring-green-200"
|
||||
: "bg-zinc-900 ring-stone-200"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<span className="text-3xl mt-0.5">{addon.icon}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2 mb-1">
|
||||
<h3 className="text-base font-semibold text-zinc-100">{addon.name}</h3>
|
||||
<span
|
||||
className={`text-xs font-medium rounded-full px-2 py-0.5 ${
|
||||
enabled
|
||||
? "bg-green-900/40 text-green-400"
|
||||
: "bg-stone-100 text-stone-500"
|
||||
}`}
|
||||
>
|
||||
{enabled ? "Enabled" : "Disabled"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-stone-700 leading-relaxed">
|
||||
{addon.description}
|
||||
</p>
|
||||
{addon.addOnPrice && !enabled && (
|
||||
<p className="mt-2 text-xs font-medium text-amber-400">
|
||||
Requires: {addon.addOnPrice}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex items-center gap-3 justify-end">
|
||||
{enabled && (
|
||||
<Link
|
||||
href={addon.adminRoute}
|
||||
className="rounded-xl border border-zinc-800 px-4 py-2 text-sm font-medium text-stone-700 hover:bg-zinc-950 transition-colors"
|
||||
>
|
||||
Open Module →
|
||||
</Link>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleToggle(key)}
|
||||
disabled={busy}
|
||||
className={`rounded-xl px-4 py-2 text-sm font-medium transition-colors disabled:opacity-50 ${
|
||||
enabled
|
||||
? "border border-red-200 text-red-400 hover:bg-red-900/30"
|
||||
: "bg-green-600 text-white hover:bg-green-700"
|
||||
}`}
|
||||
>
|
||||
{busy ? "..." : enabled ? "Disable" : "Enable"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,989 @@
|
||||
"use client";
|
||||
|
||||
import NextImage from "next/image";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import {
|
||||
getBrandSettings,
|
||||
saveBrandSettings,
|
||||
uploadBrandLogo,
|
||||
uploadOlatheSweetLogo,
|
||||
uploadOlatheSweetLogoDark,
|
||||
type BrandSettings,
|
||||
} from "@/actions/brand-settings";
|
||||
|
||||
type Props = {
|
||||
settings: BrandSettings | null;
|
||||
brandId: string;
|
||||
brandName: string;
|
||||
brands?: { id: string; name: string }[];
|
||||
isPlatformAdmin?: boolean;
|
||||
};
|
||||
|
||||
export default function BrandSettingsForm({
|
||||
settings,
|
||||
brandId: initialBrandId,
|
||||
brandName: initialBrandName,
|
||||
brands = [],
|
||||
isPlatformAdmin = false,
|
||||
}: Props) {
|
||||
const [activeBrandId, setActiveBrandId] = useState(initialBrandId);
|
||||
const [activeBrandName, setActiveBrandName] = useState(initialBrandName);
|
||||
const [legalBusinessName, setLegalBusinessName] = useState(settings?.legal_business_name ?? "");
|
||||
const [phone, setPhone] = useState(settings?.phone ?? "");
|
||||
const [email, setEmail] = useState(settings?.email ?? "");
|
||||
const [websiteUrl, setWebsiteUrl] = useState(settings?.website_url ?? "");
|
||||
const [streetAddress, setStreetAddress] = useState(settings?.street_address ?? "");
|
||||
const [city, setCity] = useState(settings?.city ?? "");
|
||||
const [state, setState] = useState(settings?.state ?? "");
|
||||
const [postalCode, setPostalCode] = useState(settings?.postal_code ?? "");
|
||||
const [country, setCountry] = useState(settings?.country ?? "US");
|
||||
const [logoUrl, setLogoUrl] = useState(settings?.logo_url ?? "");
|
||||
const [logoUrlDark, setLogoUrlDark] = useState(settings?.logo_url_dark ?? "");
|
||||
const [olatheSweetLogoUrl, setOlatheSweetLogoUrl] = useState(settings?.olathe_sweet_logo_url ?? "");
|
||||
const [olatheSweetLogoUrlDark, setOlatheSweetLogoUrlDark] = useState(settings?.olathe_sweet_logo_url_dark ?? "");
|
||||
const [defaultEmailSignature, setDefaultEmailSignature] = useState(
|
||||
settings?.default_email_signature ?? ""
|
||||
);
|
||||
const [invoiceFooterNotes, setInvoiceFooterNotes] = useState(
|
||||
settings?.invoice_footer_notes ?? ""
|
||||
);
|
||||
|
||||
// Storefront customization fields
|
||||
const [heroTagline, setHeroTagline] = useState(settings?.hero_tagline ?? "");
|
||||
const [aboutHeadline, setAboutHeadline] = useState(settings?.about_headline ?? "");
|
||||
const [aboutSubheadline, setAboutSubheadline] = useState(settings?.about_subheadline ?? "");
|
||||
const [customFooterText, setCustomFooterText] = useState(settings?.custom_footer_text ?? "");
|
||||
const [showWholesaleLink, setShowWholesaleLink] = useState(settings?.show_wholesale_link ?? true);
|
||||
const [showZipSearch, setShowZipSearch] = useState(settings?.show_zip_search ?? true);
|
||||
const [showSchedulePdf, setShowSchedulePdf] = useState(settings?.show_schedule_pdf ?? true);
|
||||
const [showTextAlerts, setShowTextAlerts] = useState(settings?.show_text_alerts ?? false);
|
||||
const [schedulePdfNotes, setSchedulePdfNotes] = useState(settings?.schedule_pdf_notes ?? "");
|
||||
const [heroImageUrl, setHeroImageUrl] = useState(settings?.hero_image_url ?? "");
|
||||
const [brandPrimaryColor, setBrandPrimaryColor] = useState(settings?.brand_primary_color ?? "#16a34a");
|
||||
const [brandSecondaryColor, setBrandSecondaryColor] = useState(settings?.brand_secondary_color ?? "#f5f5f4");
|
||||
const [brandBgColor, setBrandBgColor] = useState(settings?.brand_bg_color ?? "#fafaf9");
|
||||
const [brandTextColor, setBrandTextColor] = useState(settings?.brand_text_color ?? "#1c1917");
|
||||
// Tax settings
|
||||
const [collectSalesTax, setCollectSalesTax] = useState(settings?.collect_sales_tax ?? false);
|
||||
const [nexusStates, setNexusStates] = useState<string[]>(settings?.nexus_states ?? ["CO"]);
|
||||
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Reload settings when brand changes (platform admin)
|
||||
useEffect(() => {
|
||||
if (!isPlatformAdmin) return;
|
||||
setLoading(true);
|
||||
getBrandSettings(activeBrandId).then((result) => {
|
||||
setLoading(false);
|
||||
if (result.success && result.settings) {
|
||||
const s = result.settings;
|
||||
setLegalBusinessName(s.legal_business_name ?? "");
|
||||
setPhone(s.phone ?? "");
|
||||
setEmail(s.email ?? "");
|
||||
setWebsiteUrl(s.website_url ?? "");
|
||||
setStreetAddress(s.street_address ?? "");
|
||||
setCity(s.city ?? "");
|
||||
setState(s.state ?? "");
|
||||
setPostalCode(s.postal_code ?? "");
|
||||
setCountry(s.country ?? "US");
|
||||
setLogoUrl(s.logo_url ?? "");
|
||||
setLogoUrlDark(s.logo_url_dark ?? "");
|
||||
setOlatheSweetLogoUrl(s.olathe_sweet_logo_url ?? "");
|
||||
setOlatheSweetLogoUrlDark(s.olathe_sweet_logo_url_dark ?? "");
|
||||
setDefaultEmailSignature(s.default_email_signature ?? "");
|
||||
setInvoiceFooterNotes(s.invoice_footer_notes ?? "");
|
||||
setHeroTagline(s.hero_tagline ?? "");
|
||||
setAboutHeadline(s.about_headline ?? "");
|
||||
setAboutSubheadline(s.about_subheadline ?? "");
|
||||
setCustomFooterText(s.custom_footer_text ?? "");
|
||||
setShowWholesaleLink(s.show_wholesale_link ?? true);
|
||||
setShowZipSearch(s.show_zip_search ?? true);
|
||||
setShowSchedulePdf(s.show_schedule_pdf ?? true);
|
||||
setShowTextAlerts(s.show_text_alerts ?? false);
|
||||
setSchedulePdfNotes(s.schedule_pdf_notes ?? "");
|
||||
setHeroImageUrl(s.hero_image_url ?? "");
|
||||
setBrandPrimaryColor(s.brand_primary_color ?? "#16a34a");
|
||||
setBrandSecondaryColor(s.brand_secondary_color ?? "#f5f5f4");
|
||||
setBrandBgColor(s.brand_bg_color ?? "#fafaf9");
|
||||
setBrandTextColor(s.brand_text_color ?? "#1c1917");
|
||||
setCollectSalesTax(s.collect_sales_tax ?? false);
|
||||
setNexusStates(s.nexus_states ?? ["CO"]);
|
||||
setActiveBrandName(s.brand_name ?? "");
|
||||
} else {
|
||||
setLegalBusinessName("");
|
||||
setPhone("");
|
||||
setEmail("");
|
||||
setWebsiteUrl("");
|
||||
setStreetAddress("");
|
||||
setCity("");
|
||||
setState("");
|
||||
setPostalCode("");
|
||||
setCountry("US");
|
||||
setLogoUrl("");
|
||||
setLogoUrlDark("");
|
||||
setOlatheSweetLogoUrl("");
|
||||
setOlatheSweetLogoUrlDark("");
|
||||
setDefaultEmailSignature("");
|
||||
setInvoiceFooterNotes("");
|
||||
setHeroTagline("");
|
||||
setAboutHeadline("");
|
||||
setAboutSubheadline("");
|
||||
setCustomFooterText("");
|
||||
setShowWholesaleLink(true);
|
||||
setShowZipSearch(true);
|
||||
setShowSchedulePdf(true);
|
||||
setShowTextAlerts(false);
|
||||
setSchedulePdfNotes("");
|
||||
setHeroImageUrl("");
|
||||
setBrandPrimaryColor("#16a34a");
|
||||
setBrandSecondaryColor("#f5f5f4");
|
||||
setBrandBgColor("#fafaf9");
|
||||
setBrandTextColor("#1c1917");
|
||||
setCollectSalesTax(false);
|
||||
setNexusStates(["CO"]);
|
||||
}
|
||||
});
|
||||
}, [activeBrandId, isPlatformAdmin]);
|
||||
|
||||
async function handleSave(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
setSaved(false);
|
||||
|
||||
const result = await saveBrandSettings({
|
||||
brandId: activeBrandId,
|
||||
legalBusinessName: legalBusinessName || undefined,
|
||||
phone: phone || undefined,
|
||||
email: email || undefined,
|
||||
websiteUrl: websiteUrl || undefined,
|
||||
streetAddress: streetAddress || undefined,
|
||||
city: city || undefined,
|
||||
state: state || undefined,
|
||||
postalCode: postalCode || undefined,
|
||||
country: country || undefined,
|
||||
logoUrl: logoUrl || undefined,
|
||||
logoUrlDark: logoUrlDark || undefined,
|
||||
olatheSweetLogoUrl: olatheSweetLogoUrl || undefined,
|
||||
olatheSweetLogoUrlDark: olatheSweetLogoUrlDark || undefined,
|
||||
defaultEmailSignature: defaultEmailSignature || undefined,
|
||||
invoiceFooterNotes: invoiceFooterNotes || undefined,
|
||||
heroTagline: heroTagline || undefined,
|
||||
aboutHeadline: aboutHeadline || undefined,
|
||||
aboutSubheadline: aboutSubheadline || undefined,
|
||||
customFooterText: customFooterText || undefined,
|
||||
showWholesaleLink: showWholesaleLink,
|
||||
showZipSearch: showZipSearch,
|
||||
showSchedulePdf: showSchedulePdf,
|
||||
showTextAlerts: showTextAlerts,
|
||||
schedulePdfNotes: schedulePdfNotes || undefined,
|
||||
heroImageUrl: heroImageUrl || undefined,
|
||||
brandPrimaryColor: brandPrimaryColor || undefined,
|
||||
brandSecondaryColor: brandSecondaryColor || undefined,
|
||||
brandBgColor: brandBgColor || undefined,
|
||||
brandTextColor: brandTextColor || undefined,
|
||||
collectSalesTax,
|
||||
nexusStates,
|
||||
});
|
||||
|
||||
setSaving(false);
|
||||
if (!result.success) {
|
||||
setError(result.error ?? "Failed to save");
|
||||
} else {
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 3000);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSave} className="space-y-8">
|
||||
{/* Platform admin brand picker */}
|
||||
{isPlatformAdmin && brands.length > 0 && (
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-zinc-300">Brand</label>
|
||||
<select
|
||||
value={activeBrandId}
|
||||
onChange={(e) => setActiveBrandId(e.target.value)}
|
||||
className="w-full rounded-xl border border-zinc-700 bg-zinc-800 px-4 py-3 text-base text-zinc-100 outline-none focus:border-emerald-500"
|
||||
>
|
||||
{brands.map((b) => (
|
||||
<option key={b.id} value={b.id}>{b.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="mt-1 text-xs text-zinc-500">
|
||||
Viewing <strong className="text-zinc-300">{activeBrandName}</strong>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="rounded-xl bg-red-950/50 border border-red-800 p-4 text-sm text-red-300">{error}</div>
|
||||
)}
|
||||
{saved && (
|
||||
<div className="rounded-xl bg-green-950/50 border border-green-800 p-4 text-sm text-green-300">
|
||||
Brand settings saved.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Company Info */}
|
||||
<div className="space-y-5 rounded-xl border border-zinc-700 p-5">
|
||||
<div>
|
||||
<h3 className="font-semibold text-zinc-100">Company Information</h3>
|
||||
<p className="mt-1 text-sm text-zinc-400">
|
||||
Primary contact and business details for this brand.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300">Legal Business Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={legalBusinessName}
|
||||
onChange={(e) => setLegalBusinessName(e.target.value)}
|
||||
placeholder="Tuxedo Corn LLC"
|
||||
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-sm text-zinc-100 outline-none focus:border-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300">Phone</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
placeholder="(772) 555-0100"
|
||||
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-sm text-zinc-100 outline-none focus:border-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="orders@tuxedocorn.com"
|
||||
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-sm text-zinc-100 outline-none focus:border-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300">Website</label>
|
||||
<input
|
||||
type="url"
|
||||
value={websiteUrl}
|
||||
onChange={(e) => setWebsiteUrl(e.target.value)}
|
||||
placeholder="https://tuxedocorn.com"
|
||||
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-sm text-zinc-100 outline-none focus:border-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Address */}
|
||||
<div className="space-y-5 rounded-xl border border-zinc-700 p-5">
|
||||
<div>
|
||||
<h3 className="font-semibold text-zinc-100">Business Address</h3>
|
||||
<p className="mt-1 text-sm text-zinc-400">
|
||||
Used on invoices and shipping labels.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300">Street Address</label>
|
||||
<input
|
||||
type="text"
|
||||
value={streetAddress}
|
||||
onChange={(e) => setStreetAddress(e.target.value)}
|
||||
placeholder="1234 Farm Road"
|
||||
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-sm text-zinc-100 outline-none focus:border-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||
<div className="col-span-2">
|
||||
<label className="block text-sm font-medium text-zinc-300">City</label>
|
||||
<input
|
||||
type="text"
|
||||
value={city}
|
||||
onChange={(e) => setCity(e.target.value)}
|
||||
placeholder="Stuart"
|
||||
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-sm text-zinc-100 outline-none focus:border-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300">State</label>
|
||||
<input
|
||||
type="text"
|
||||
value={state}
|
||||
onChange={(e) => setState(e.target.value)}
|
||||
placeholder="FL"
|
||||
maxLength={2}
|
||||
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-sm uppercase text-zinc-100 outline-none focus:border-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300">ZIP / Postal Code</label>
|
||||
<input
|
||||
type="text"
|
||||
value={postalCode}
|
||||
onChange={(e) => setPostalCode(e.target.value)}
|
||||
placeholder="34994"
|
||||
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-sm text-zinc-100 outline-none focus:border-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300">Country</label>
|
||||
<select
|
||||
value={country}
|
||||
onChange={(e) => setCountry(e.target.value)}
|
||||
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-sm text-zinc-100 outline-none focus:border-emerald-500"
|
||||
>
|
||||
<option value="US">United States</option>
|
||||
<option value="CA">Canada</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Brand Logos */}
|
||||
<div className="space-y-5 rounded-xl border border-zinc-700 p-5">
|
||||
<div>
|
||||
<h3 className="font-semibold text-zinc-100">Brand Logos</h3>
|
||||
<p className="mt-1 text-sm text-zinc-400">
|
||||
Upload your brand logos to Supabase Storage. Recommended: 1200×400px max, 5MB max (ideally under 2MB for faster loading).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||
{/* Primary Logo */}
|
||||
<LogoUploadField
|
||||
label="Primary Brand Logo"
|
||||
hint="Light backgrounds — invoices, email headers, storefront header"
|
||||
value={logoUrl}
|
||||
previewBg="bg-zinc-800"
|
||||
brandId={activeBrandId}
|
||||
onUpload={(url) => setLogoUrl(url)}
|
||||
/>
|
||||
|
||||
{/* Dark Logo */}
|
||||
<LogoUploadField
|
||||
label="Dark Background Logo"
|
||||
hint="Dark backgrounds — storefront nav on dark header"
|
||||
value={logoUrlDark}
|
||||
previewBg="bg-zinc-900"
|
||||
brandId={activeBrandId}
|
||||
onUpload={(url) => setLogoUrlDark(url)}
|
||||
isDark
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Olathe Sweet Logos — two variants */}
|
||||
<div className="mt-2">
|
||||
<p className="text-sm font-semibold text-zinc-200 mb-4">Olathe Sweet™ Logo</p>
|
||||
<p className="text-xs text-zinc-400 mb-4">
|
||||
The mountain & wagon wheel mark — use the light version on dark backgrounds, dark/white version on light backgrounds. Shown in hero, About page, and product cards.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||
<LogoUploadField
|
||||
label="Olathe Sweet™ — Dark/White"
|
||||
hint="Works on light backgrounds (stone, white)"
|
||||
value={olatheSweetLogoUrl}
|
||||
previewBg="bg-zinc-700"
|
||||
brandId={activeBrandId}
|
||||
onUpload={(url) => setOlatheSweetLogoUrl(url)}
|
||||
isOlatheSweetLight
|
||||
/>
|
||||
<LogoUploadField
|
||||
label="Olathe Sweet™ — Transparent"
|
||||
hint="Works on dark backgrounds (hero, dark sections)"
|
||||
value={olatheSweetLogoUrlDark}
|
||||
previewBg="bg-zinc-900"
|
||||
brandId={activeBrandId}
|
||||
onUpload={(url) => setOlatheSweetLogoUrlDark(url)}
|
||||
isOlatheSweetDark
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Email & Invoice branding */}
|
||||
<div className="space-y-5 rounded-xl border border-zinc-700 p-5">
|
||||
<div>
|
||||
<h3 className="font-semibold text-zinc-100">Email & Invoice Branding</h3>
|
||||
<p className="mt-1 text-sm text-zinc-400">
|
||||
Used in email signatures, invoice footers, and customer-facing documents.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300 mb-1">
|
||||
Default Email Signature
|
||||
</label>
|
||||
<textarea
|
||||
value={defaultEmailSignature}
|
||||
onChange={(e) => setDefaultEmailSignature(e.target.value)}
|
||||
rows={3}
|
||||
placeholder="The Tuxedo Corn Team orders@tuxedocorn.com | (772) 555-0100 Fresh from Florida since 1987"
|
||||
className="w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-sm text-zinc-100 outline-none focus:border-emerald-500"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-zinc-500">
|
||||
Appears at the bottom of all outbound emails. Supports line breaks.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300 mb-1">
|
||||
Invoice Footer Notes
|
||||
</label>
|
||||
<textarea
|
||||
value={invoiceFooterNotes}
|
||||
onChange={(e) => setInvoiceFooterNotes(e.target.value)}
|
||||
rows={2}
|
||||
placeholder="Thank you for your order! Pickup available at our farm stand. Questions? Call (772) 555-0100 or email orders@tuxedocorn.com"
|
||||
className="w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-sm text-zinc-100 outline-none focus:border-emerald-500"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-zinc-500">
|
||||
Printed at the bottom of invoices and order confirmations.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Storefront Customization */}
|
||||
<div className="space-y-5 rounded-xl border border-zinc-700 p-5">
|
||||
<div>
|
||||
<h3 className="font-semibold text-zinc-100">Storefront Customization</h3>
|
||||
<p className="mt-1 text-sm text-zinc-400">
|
||||
Controls what appears on your public storefront homepage.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300 mb-1">Hero Tagline</label>
|
||||
<input
|
||||
type="text"
|
||||
value={heroTagline}
|
||||
onChange={(e) => setHeroTagline(e.target.value)}
|
||||
placeholder="Fresh citrus delivered to stops near you."
|
||||
className="w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-sm text-zinc-100 outline-none focus:border-emerald-500"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-zinc-500">Subtitle below the brand name in the hero section.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300 mb-1">Hero Image URL</label>
|
||||
<input
|
||||
type="url"
|
||||
value={heroImageUrl}
|
||||
onChange={(e) => setHeroImageUrl(e.target.value)}
|
||||
placeholder="https://cdn.example.com/hero-corn-field.jpg"
|
||||
className="w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-sm text-zinc-100 outline-none focus:border-emerald-500"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-zinc-500">
|
||||
Full-width hero background image. Recommended: 1600×600px, under 1MB. Paste a public image URL.
|
||||
For Tuxedo Corn: corn field photo. For IRD: citrus orchard.
|
||||
</p>
|
||||
{heroImageUrl && (
|
||||
<div className="relative mt-2 rounded-lg overflow-hidden border border-zinc-700 h-32">
|
||||
<NextImage src={heroImageUrl} alt="Hero preview" fill style={{ objectFit: "cover" }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300 mb-1">About Page Headline</label>
|
||||
<input
|
||||
type="text"
|
||||
value={aboutHeadline}
|
||||
onChange={(e) => setAboutHeadline(e.target.value)}
|
||||
placeholder="Our Story"
|
||||
className="w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-sm text-zinc-100 outline-none focus:border-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300 mb-1">About Page Subheadline</label>
|
||||
<input
|
||||
type="text"
|
||||
value={aboutSubheadline}
|
||||
onChange={(e) => setAboutSubheadline(e.target.value)}
|
||||
placeholder="Growing citrus in the Indian River region since 1985."
|
||||
className="w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-sm text-zinc-100 outline-none focus:border-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300 mb-1">Custom Footer Text</label>
|
||||
<textarea
|
||||
value={customFooterText}
|
||||
onChange={(e) => setCustomFooterText(e.target.value)}
|
||||
rows={2}
|
||||
placeholder="Fresh from Florida — Ships Sept through May"
|
||||
className="w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-sm text-zinc-100 outline-none focus:border-emerald-500"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-zinc-500">Appears above the copyright line in the brand footer.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Brand Colors */}
|
||||
<div className="space-y-5 rounded-xl border border-zinc-700 p-5">
|
||||
<div>
|
||||
<h3 className="font-semibold text-zinc-100">Brand Colors</h3>
|
||||
<p className="mt-1 text-sm text-zinc-400">
|
||||
Customize your storefront accent and background colors. Use hex codes (e.g. #e11d48).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300 mb-1">Primary Accent</label>
|
||||
<div className="flex gap-2 items-center">
|
||||
<input
|
||||
type="color"
|
||||
value={brandPrimaryColor}
|
||||
onChange={(e) => setBrandPrimaryColor(e.target.value)}
|
||||
className="w-10 h-10 rounded-lg border border-zinc-600 cursor-pointer"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={brandPrimaryColor}
|
||||
onChange={(e) => setBrandPrimaryColor(e.target.value)}
|
||||
className="flex-1 rounded-xl border border-zinc-600 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 outline-none focus:border-emerald-500 font-mono"
|
||||
placeholder="#16a34a"
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-zinc-500">Buttons, badges, links</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300 mb-1">Text Color</label>
|
||||
<div className="flex gap-2 items-center">
|
||||
<input
|
||||
type="color"
|
||||
value={brandTextColor}
|
||||
onChange={(e) => setBrandTextColor(e.target.value)}
|
||||
className="w-10 h-10 rounded-lg border border-zinc-600 cursor-pointer"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={brandTextColor}
|
||||
onChange={(e) => setBrandTextColor(e.target.value)}
|
||||
className="flex-1 rounded-xl border border-zinc-600 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 outline-none focus:border-emerald-500 font-mono"
|
||||
placeholder="#1c1917"
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-zinc-500">Headlines, body text</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300 mb-1">Background Color</label>
|
||||
<div className="flex gap-2 items-center">
|
||||
<input
|
||||
type="color"
|
||||
value={brandBgColor}
|
||||
onChange={(e) => setBrandBgColor(e.target.value)}
|
||||
className="w-10 h-10 rounded-lg border border-zinc-600 cursor-pointer"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={brandBgColor}
|
||||
onChange={(e) => setBrandBgColor(e.target.value)}
|
||||
className="flex-1 rounded-xl border border-zinc-600 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 outline-none focus:border-emerald-500 font-mono"
|
||||
placeholder="#fafaf9"
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-zinc-500">Page background (light mode)</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300 mb-1">Secondary Accent</label>
|
||||
<div className="flex gap-2 items-center">
|
||||
<input
|
||||
type="color"
|
||||
value={brandSecondaryColor}
|
||||
onChange={(e) => setBrandSecondaryColor(e.target.value)}
|
||||
className="w-10 h-10 rounded-lg border border-zinc-600 cursor-pointer"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={brandSecondaryColor}
|
||||
onChange={(e) => setBrandSecondaryColor(e.target.value)}
|
||||
className="flex-1 rounded-xl border border-zinc-600 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 outline-none focus:border-emerald-500 font-mono"
|
||||
placeholder="#f5f5f4"
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-zinc-500">Hover states, highlights</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 p-4 rounded-xl bg-zinc-800 border border-zinc-700">
|
||||
<p className="text-xs text-zinc-400 mb-2 font-medium">Preview</p>
|
||||
<div className="flex gap-3 items-center flex-wrap">
|
||||
<div className="flex gap-2">
|
||||
<div className="w-8 h-8 rounded-lg flex items-center justify-center text-white text-xs font-bold" style={{ backgroundColor: brandPrimaryColor }}>
|
||||
A
|
||||
</div>
|
||||
<div className="w-8 h-8 rounded-lg flex items-center justify-center text-xs font-bold border border-zinc-600" style={{ backgroundColor: brandBgColor, color: brandTextColor }}>
|
||||
A
|
||||
</div>
|
||||
<div className="w-8 h-8 rounded-lg border border-zinc-600" style={{ backgroundColor: brandSecondaryColor }} />
|
||||
</div>
|
||||
<div className="h-8 w-px bg-zinc-600" />
|
||||
<div className="flex gap-1.5">
|
||||
<div className="px-3 py-1.5 rounded-lg text-white text-xs font-semibold" style={{ backgroundColor: brandPrimaryColor }}>
|
||||
Button
|
||||
</div>
|
||||
<div className="px-3 py-1.5 rounded-lg text-xs font-semibold border" style={{ borderColor: brandPrimaryColor, color: brandPrimaryColor }}>
|
||||
Outline
|
||||
</div>
|
||||
<div className="px-3 py-1.5 rounded-lg text-xs font-semibold" style={{ backgroundColor: brandSecondaryColor, color: brandTextColor }}>
|
||||
Secondary
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tax Settings */}
|
||||
<div className="space-y-5 rounded-xl border border-amber-700 bg-amber-950/30 p-5">
|
||||
<div>
|
||||
<h3 className="font-semibold text-amber-200">Tax Settings</h3>
|
||||
<p className="mt-1 text-sm text-amber-300/70">
|
||||
Configure automatic sales tax collection via Stripe Tax. Tax is only calculated for ship orders shipping to your nexus states.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<FeatureToggle
|
||||
label="Collect Sales Tax"
|
||||
description="Automatically calculate and collect sales tax for ship orders via Stripe Tax."
|
||||
checked={collectSalesTax}
|
||||
onChange={setCollectSalesTax}
|
||||
/>
|
||||
|
||||
{collectSalesTax && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-amber-200 mb-1">
|
||||
Nexus States
|
||||
</label>
|
||||
<p className="mt-1 text-xs text-amber-300/60 mb-2">
|
||||
States where you have tax nexus (physical presence). Stripe Tax will calculate tax for ship orders to these states.
|
||||
</p>
|
||||
<NexusStateInput
|
||||
value={nexusStates}
|
||||
onChange={setNexusStates}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Feature Toggles */}
|
||||
<div className="space-y-5 rounded-xl border border-zinc-700 p-5">
|
||||
<div>
|
||||
<h3 className="font-semibold text-zinc-100">Feature Toggles</h3>
|
||||
<p className="mt-1 text-sm text-zinc-400">
|
||||
Show or hide features on your public storefront.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<FeatureToggle
|
||||
label="Wholesale Portal link"
|
||||
description="Show the green Wholesale Portal banner on the homepage and nav link."
|
||||
checked={showWholesaleLink}
|
||||
onChange={setShowWholesaleLink}
|
||||
/>
|
||||
<FeatureToggle
|
||||
label="Zip Code Search"
|
||||
description="Show the ZIP code search box on the IRD homepage."
|
||||
checked={showZipSearch}
|
||||
onChange={setShowZipSearch}
|
||||
/>
|
||||
<FeatureToggle
|
||||
label="Schedule PDF Download"
|
||||
description="Show the 'Download Schedule PDF' button on the stops section."
|
||||
checked={showSchedulePdf}
|
||||
onChange={setShowSchedulePdf}
|
||||
/>
|
||||
<FeatureToggle
|
||||
label="Text Alerts Signup"
|
||||
description="Show a text alert signup section on the homepage."
|
||||
checked={showTextAlerts}
|
||||
onChange={setShowTextAlerts}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300 mb-1">Schedule PDF Footer Notes</label>
|
||||
<textarea
|
||||
value={schedulePdfNotes}
|
||||
onChange={(e) => setSchedulePdfNotes(e.target.value)}
|
||||
rows={1}
|
||||
placeholder="All orders prepaid. No refunds on unpicked orders."
|
||||
className="w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-sm text-zinc-100 outline-none focus:border-emerald-500"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-zinc-500">Printed at the bottom of the generated schedule PDF.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-3 text-zinc-400">
|
||||
<div className="h-5 w-5 rounded-full border-2 border-zinc-500 border-t-transparent animate-spin" />
|
||||
Loading settings...
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="rounded-xl bg-blue-600 px-6 py-3 text-sm font-bold text-white hover:bg-blue-900/300 disabled:opacity-50"
|
||||
>
|
||||
{saving ? "Saving..." : "Save Brand Settings"}
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Feature Toggle Component ─────────────────────────────────────────────────
|
||||
|
||||
function FeatureToggle({
|
||||
label,
|
||||
description,
|
||||
checked,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
description: string;
|
||||
checked: boolean;
|
||||
onChange: (val: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-start gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(!checked)}
|
||||
className={`relative mt-0.5 inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full transition-colors ${
|
||||
checked ? "bg-green-600" : "bg-zinc-600"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-3.5 w-3.5 transform rounded-full bg-zinc-900 transition-transform ${
|
||||
checked ? "translate-x-4" : "translate-x-1"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-zinc-200">{label}</p>
|
||||
<p className="text-xs text-zinc-400">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Nexus States Input Component ──────────────────────────────────────────────
|
||||
|
||||
const US_STATES = [
|
||||
"AL","AK","AZ","AR","CA","CO","CT","DE","FL","GA",
|
||||
"HI","ID","IL","IN","IA","KS","KY","LA","ME","MD",
|
||||
"MA","MI","MN","MS","MO","MT","NE","NV","NH","NJ",
|
||||
"NM","NY","NC","ND","OH","OK","OR","PA","RI","SC",
|
||||
"SD","TN","TX","UT","VT","VA","WA","WV","WI","WY",
|
||||
];
|
||||
|
||||
function NexusStateInput({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: string[];
|
||||
onChange: (v: string[]) => void;
|
||||
}) {
|
||||
const [input, setInput] = useState("");
|
||||
|
||||
function addState(code: string) {
|
||||
const upper = code.toUpperCase().trim();
|
||||
if (US_STATES.includes(upper) && !value.includes(upper)) {
|
||||
onChange([...value, upper]);
|
||||
}
|
||||
setInput("");
|
||||
}
|
||||
|
||||
function removeState(code: string) {
|
||||
onChange(value.filter((s) => s !== code));
|
||||
}
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
|
||||
if (e.key === "Enter" || e.key === ",") {
|
||||
e.preventDefault();
|
||||
if (input.trim()) addState(input);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{value.map((code) => (
|
||||
<span
|
||||
key={code}
|
||||
className="inline-flex items-center gap-1 rounded-lg bg-amber-600 text-amber-050 px-2 py-1 text-xs font-semibold"
|
||||
>
|
||||
{code}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeState(code)}
|
||||
className="hover:text-amber-200"
|
||||
>
|
||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Type state code (e.g. CO) and press Enter"
|
||||
className="flex-1 rounded-xl border border-zinc-600 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 outline-none focus:border-emerald-500 uppercase"
|
||||
maxLength={2}
|
||||
/>
|
||||
<div className="relative">
|
||||
<select
|
||||
value=""
|
||||
onChange={(e) => { if (e.target.value) addState(e.target.value); }}
|
||||
className="h-[42px] rounded-xl border border-zinc-600 bg-zinc-800 px-3 text-sm text-zinc-100 outline-none focus:border-emerald-500"
|
||||
>
|
||||
<option value="">Add state</option>
|
||||
{US_STATES.filter((s) => !value.includes(s)).map((s) => (
|
||||
<option key={s} value={s}>{s}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Logo Upload Component ────────────────────────────────────────────────────
|
||||
|
||||
type LogoUploadFieldProps = {
|
||||
label: string;
|
||||
hint: string;
|
||||
value: string;
|
||||
previewBg: string;
|
||||
brandId: string;
|
||||
onUpload: (url: string) => void;
|
||||
isDark?: boolean;
|
||||
isOlatheSweetLight?: boolean;
|
||||
isOlatheSweetDark?: boolean;
|
||||
};
|
||||
|
||||
export function LogoUploadField({
|
||||
label,
|
||||
hint,
|
||||
value,
|
||||
previewBg,
|
||||
brandId,
|
||||
onUpload,
|
||||
isDark = false,
|
||||
isOlatheSweetLight = false,
|
||||
isOlatheSweetDark = false,
|
||||
}: LogoUploadFieldProps) {
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
async function handleFile(file: File) {
|
||||
setError(null);
|
||||
setUploading(true);
|
||||
let result;
|
||||
if (isOlatheSweetLight) {
|
||||
result = await uploadOlatheSweetLogo(brandId, file);
|
||||
} else if (isOlatheSweetDark) {
|
||||
result = await uploadOlatheSweetLogoDark(brandId, file);
|
||||
} else {
|
||||
result = await uploadBrandLogo(brandId, file, isDark);
|
||||
}
|
||||
setUploading(false);
|
||||
if (result.success) {
|
||||
onUpload(result.logoUrl);
|
||||
} else {
|
||||
setError(result.error ?? "Upload failed");
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300 mb-1">{label}</label>
|
||||
<p
|
||||
className="text-xs text-zinc-500 mb-2"
|
||||
dangerouslySetInnerHTML={{ __html: hint }}
|
||||
/>
|
||||
|
||||
<div
|
||||
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
className={`
|
||||
relative flex flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed p-6 cursor-pointer transition-colors
|
||||
${dragOver ? "border-emerald-500 bg-emerald-950/30" : "border-zinc-600 hover:border-zinc-500 hover:bg-zinc-800/50"}
|
||||
${uploading ? "opacity-50 pointer-events-none" : ""}
|
||||
`}
|
||||
>
|
||||
{uploading ? (
|
||||
<>
|
||||
<div className="h-5 w-5 rounded-full border-2 border-zinc-500 border-t-transparent animate-spin" />
|
||||
<span className="text-sm text-zinc-400">Uploading...</span>
|
||||
</>
|
||||
) : value ? (
|
||||
<>
|
||||
<span className="relative inline-block h-10 w-auto">
|
||||
<NextImage src={value} alt={label} fill style={{ objectFit: "contain" }} />
|
||||
</span>
|
||||
<span className="text-xs text-zinc-400">Click or drop to replace</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="h-8 w-8 text-zinc-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<span className="text-sm text-zinc-400">Drag & drop or click to upload</span>
|
||||
<span className="text-xs text-zinc-500">PNG, JPEG, WebP · 1200×400px max · max 5MB (ideally under 2MB)</span>
|
||||
</>
|
||||
)}
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp,image/svg+xml"
|
||||
className="hidden"
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="mt-1 text-xs text-red-400">{error}</p>}
|
||||
|
||||
{value && (
|
||||
<div className={`mt-3 rounded-lg p-3 text-center ${previewBg}`}>
|
||||
<p className="text-xs text-zinc-400 mb-2">Preview</p>
|
||||
<span className="relative inline-block h-10 w-auto">
|
||||
<NextImage src={value} alt={label} fill style={{ objectFit: "contain" }} className="mx-auto" />
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,498 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { Campaign, CampaignType, CampaignStatus, AudienceRules } from "@/actions/communications/campaigns";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import type { Template } from "@/actions/communications/templates";
|
||||
import type { AudiencePreviewResult } from "@/actions/communications/send";
|
||||
import { previewCampaignAudience, sendCampaign } from "@/actions/communications/send";
|
||||
import { upsertCampaign, deleteCampaign } from "@/actions/communications/campaigns";
|
||||
import { getCommunicationTemplates } from "@/actions/communications/templates";
|
||||
import { getCommunicationSegments, type Segment } from "@/actions/communications/segments";
|
||||
|
||||
const BRAND_ID = process.env.NEXT_PUBLIC_TUXEDO_BRAND_ID ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
|
||||
const CAMPAIGN_TYPES: { value: CampaignType; label: string }[] = [
|
||||
{ value: "operational", label: "Operational" },
|
||||
{ value: "marketing", label: "Marketing" },
|
||||
{ value: "transactional", label: "Transactional" },
|
||||
];
|
||||
|
||||
const STATUS_COLORS: Record<CampaignStatus, string> = {
|
||||
draft: "bg-zinc-950 text-zinc-300",
|
||||
scheduled: "bg-blue-900/40 text-blue-700",
|
||||
sending: "bg-yellow-100 text-yellow-700",
|
||||
sent: "bg-green-900/40 text-green-400",
|
||||
canceled: "bg-red-900/40 text-red-400",
|
||||
};
|
||||
|
||||
export default function CampaignListPanel({ initialCampaigns }: { initialCampaigns: Campaign[] }) {
|
||||
const router = useRouter();
|
||||
const [campaigns, setCampaigns] = useState(initialCampaigns);
|
||||
const [filterType, setFilterType] = useState<CampaignType | "all">("all");
|
||||
const [filterStatus, setFilterStatus] = useState<CampaignStatus | "all">("all");
|
||||
|
||||
const filtered = campaigns.filter((c) => {
|
||||
if (filterType !== "all" && c.campaign_type !== filterType) return false;
|
||||
if (filterStatus !== "all" && c.status !== filterStatus) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-zinc-100">Campaigns</h2>
|
||||
<p className="text-sm text-zinc-500">{filtered.length} campaign{filtered.length !== 1 ? "s" : ""}</p>
|
||||
</div>
|
||||
<a
|
||||
href="/admin/communications/campaigns/new"
|
||||
className="inline-flex items-center gap-1 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
|
||||
>
|
||||
+ New Campaign
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 mb-4">
|
||||
<select
|
||||
className="text-sm border border-zinc-600 rounded-lg px-3 py-2"
|
||||
value={filterType}
|
||||
onChange={(e) => setFilterType(e.target.value as CampaignType | "all")}
|
||||
>
|
||||
<option value="all">All Types</option>
|
||||
{CAMPAIGN_TYPES.map((t) => (
|
||||
<option key={t.value} value={t.value}>{t.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className="text-sm border border-zinc-600 rounded-lg px-3 py-2"
|
||||
value={filterStatus}
|
||||
onChange={(e) => setFilterStatus(e.target.value as CampaignStatus | "all")}
|
||||
>
|
||||
<option value="all">All Statuses</option>
|
||||
<option value="draft">Draft</option>
|
||||
<option value="scheduled">Scheduled</option>
|
||||
<option value="sent">Sent</option>
|
||||
<option value="canceled">Canceled</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<div className="text-center py-12 text-slate-400">No campaigns found</div>
|
||||
) : (
|
||||
<div className="bg-zinc-900 rounded-xl border border-zinc-800 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-50 border-b border-zinc-800">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3 font-medium text-zinc-400">Name</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-zinc-400">Type</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-zinc-400">Status</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-zinc-400">Created</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-zinc-400">Sent</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{filtered.map((c) => (
|
||||
<tr key={c.id} className="hover:bg-zinc-800 cursor-pointer" onClick={() => router.push(`/admin/communications/campaigns/${c.id}`)}>
|
||||
<td className="px-4 py-3 font-medium text-zinc-100">{c.name}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${
|
||||
c.campaign_type === "operational" ? "bg-purple-100 text-purple-700" :
|
||||
c.campaign_type === "marketing" ? "bg-orange-100 text-orange-700" :
|
||||
"bg-zinc-950 text-zinc-400"
|
||||
}`}>
|
||||
{c.campaign_type}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${STATUS_COLORS[c.status]}`}>
|
||||
{c.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-zinc-500">{formatDate(new Date(c.created_at))}</td>
|
||||
<td className="px-4 py-3 text-zinc-500">{c.sent_at ? formatDate(new Date(c.sent_at)) : "—"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Campaign Edit Panel ──────────────────────────────────────────────────────
|
||||
|
||||
export function CampaignEditPanel({
|
||||
campaign,
|
||||
templates,
|
||||
mode,
|
||||
brandId,
|
||||
}: {
|
||||
campaign?: Campaign;
|
||||
templates: Template[];
|
||||
mode: "edit" | "new";
|
||||
brandId: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [name, setName] = useState(campaign?.name ?? "");
|
||||
const [subject, setSubject] = useState(campaign?.subject ?? "");
|
||||
const [bodyText, setBodyText] = useState(campaign?.body_text ?? "");
|
||||
const [bodyHtml, setBodyHtml] = useState(campaign?.body_html ?? "");
|
||||
const [templateId, setTemplateId] = useState(campaign?.template_id ?? "");
|
||||
const [campaignType, setCampaignType] = useState<CampaignType>(campaign?.campaign_type ?? "operational");
|
||||
const [audienceTarget, setAudienceTarget] = useState<string>(campaign?.audience_rules?.target ?? "stop");
|
||||
const [stopId, setStopId] = useState(campaign?.audience_rules?.stop_id ?? "");
|
||||
const [dateFrom, setDateFrom] = useState(campaign?.audience_rules?.date_from ?? "");
|
||||
const [dateTo, setDateTo] = useState(campaign?.audience_rules?.date_to ?? "");
|
||||
const [scheduleMode, setScheduleMode] = useState<"now" | "later">(
|
||||
campaign?.scheduled_at ? "later" : "now"
|
||||
);
|
||||
const [scheduledAt, setScheduledAt] = useState(
|
||||
campaign?.scheduled_at
|
||||
? new Date(campaign.scheduled_at).toISOString().slice(0, 16)
|
||||
: ""
|
||||
);
|
||||
const [segments, setSegments] = useState<Segment[]>([]);
|
||||
const [selectedSegmentId, setSelectedSegmentId] = useState<string>("");
|
||||
const [preview, setPreview] = useState<AudiencePreviewResult | null>(null);
|
||||
const [previewLoading, setPreviewLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
// Load saved segments
|
||||
useEffect(() => {
|
||||
getCommunicationSegments(brandId).then((result) => {
|
||||
if (result.success) setSegments(result.segments);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// When a segment is selected, populate audience fields
|
||||
function applySegment(segmentId: string) {
|
||||
setSelectedSegmentId(segmentId);
|
||||
if (!segmentId) return;
|
||||
const seg = segments.find((s) => s.id === segmentId);
|
||||
if (!seg) return;
|
||||
const rules = seg.rules;
|
||||
if (rules.target) setAudienceTarget(rules.target);
|
||||
if (rules.stop_id) setStopId(rules.stop_id);
|
||||
if (rules.date_from) setDateFrom(rules.date_from);
|
||||
if (rules.date_to) setDateTo(rules.date_to);
|
||||
}
|
||||
|
||||
const loadPreview = async () => {
|
||||
setPreviewLoading(true);
|
||||
const rules: AudienceRules = {
|
||||
target: audienceTarget as AudienceRules["target"],
|
||||
stop_id: stopId || undefined,
|
||||
date_from: dateFrom || undefined,
|
||||
date_to: dateTo || undefined,
|
||||
};
|
||||
const result = await previewCampaignAudience(brandId, rules);
|
||||
setPreview(result);
|
||||
setPreviewLoading(false);
|
||||
};
|
||||
|
||||
const applyTemplate = (t: Template) => {
|
||||
setSubject(t.subject);
|
||||
setBodyText(t.body_text);
|
||||
setBodyHtml(t.body_html ?? "");
|
||||
setCampaignType((t.campaign_type as CampaignType) ?? "operational");
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
setError("");
|
||||
const rules: AudienceRules = {
|
||||
target: audienceTarget as AudienceRules["target"],
|
||||
stop_id: stopId || undefined,
|
||||
date_from: dateFrom || undefined,
|
||||
date_to: dateTo || undefined,
|
||||
};
|
||||
const effectiveScheduledAt =
|
||||
scheduleMode === "later" && scheduledAt
|
||||
? new Date(scheduledAt).toISOString()
|
||||
: null;
|
||||
const result = await upsertCampaign({
|
||||
id: campaign?.id,
|
||||
brand_id: brandId,
|
||||
name,
|
||||
subject,
|
||||
body_text: bodyText,
|
||||
body_html: bodyHtml || undefined,
|
||||
template_id: templateId || undefined,
|
||||
campaign_type: campaignType,
|
||||
status: scheduleMode === "later" ? "scheduled" : (campaign?.status ?? "draft"),
|
||||
audience_rules: rules,
|
||||
scheduled_at: effectiveScheduledAt ?? undefined,
|
||||
});
|
||||
setSaving(false);
|
||||
if (!result.success) {
|
||||
setError(result.error ?? "Failed to save");
|
||||
return;
|
||||
}
|
||||
router.push("/admin/communications");
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
const handleSend = async () => {
|
||||
setSending(true);
|
||||
setError("");
|
||||
if (!campaign?.id) {
|
||||
setError("Save as draft first");
|
||||
setSending(false);
|
||||
return;
|
||||
}
|
||||
const result = await sendCampaign(campaign.id, brandId);
|
||||
setSending(false);
|
||||
if (!result.success) {
|
||||
setError(result.error ?? "Failed to send");
|
||||
return;
|
||||
}
|
||||
router.push("/admin/communications");
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-xl font-semibold text-zinc-100">
|
||||
{mode === "new" ? "New Campaign" : "Edit Campaign"}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded-lg bg-red-900/30 border border-red-200 px-4 py-3 text-sm text-red-400">{error}</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Basic info */}
|
||||
<div className="bg-zinc-900 rounded-xl border border-zinc-800 p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300 mb-1">Campaign Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full border border-zinc-600 rounded-lg px-3 py-2 text-sm"
|
||||
placeholder="e.g. Tuxedo Stop 5/15 Reminder"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300 mb-1">Campaign Type *</label>
|
||||
<select
|
||||
value={campaignType}
|
||||
onChange={(e) => setCampaignType(e.target.value as CampaignType)}
|
||||
className="w-full border border-zinc-600 rounded-lg px-3 py-2 text-sm"
|
||||
>
|
||||
{CAMPAIGN_TYPES.map((t) => (
|
||||
<option key={t.value} value={t.value}>{t.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300 mb-1">Template</label>
|
||||
<select
|
||||
value={templateId}
|
||||
onChange={(e) => {
|
||||
setTemplateId(e.target.value);
|
||||
const t = templates.find((t) => t.id === e.target.value);
|
||||
if (t) applyTemplate(t);
|
||||
}}
|
||||
className="w-full border border-zinc-600 rounded-lg px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">No template</option>
|
||||
{templates.map((t) => (
|
||||
<option key={t.id} value={t.id}>{t.name} ({t.template_type})</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Audience builder */}
|
||||
<div className="bg-zinc-900 rounded-xl border border-zinc-800 p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-slate-800">Audience</h3>
|
||||
{segments.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-zinc-500">Saved segment:</span>
|
||||
<select
|
||||
value={selectedSegmentId}
|
||||
onChange={(e) => applySegment(e.target.value)}
|
||||
className="text-sm border border-zinc-600 rounded-lg px-3 py-1.5"
|
||||
>
|
||||
<option value="">— None —</option>
|
||||
{segments.map((s) => (
|
||||
<option key={s.id} value={s.id}>{s.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300 mb-1">Target By</label>
|
||||
<select
|
||||
value={audienceTarget}
|
||||
onChange={(e) => {
|
||||
setAudienceTarget(e.target.value);
|
||||
setSelectedSegmentId("");
|
||||
}}
|
||||
className="w-full border border-zinc-600 rounded-lg px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="stop">Stop / Date Range</option>
|
||||
<option value="zip_code">ZIP Code</option>
|
||||
<option value="customer_history">Customer History</option>
|
||||
<option value="all_customers">All Customers</option>
|
||||
</select>
|
||||
</div>
|
||||
{audienceTarget === "stop" && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300 mb-1">Stop ID</label>
|
||||
<input
|
||||
type="text"
|
||||
value={stopId}
|
||||
onChange={(e) => setStopId(e.target.value)}
|
||||
className="w-full border border-zinc-600 rounded-lg px-3 py-2 text-sm"
|
||||
placeholder="UUID"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300 mb-1">From</label>
|
||||
<input
|
||||
type="date"
|
||||
value={dateFrom}
|
||||
onChange={(e) => setDateFrom(e.target.value)}
|
||||
className="w-full border border-zinc-600 rounded-lg px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300 mb-1">To</label>
|
||||
<input
|
||||
type="date"
|
||||
value={dateTo}
|
||||
onChange={(e) => setDateTo(e.target.value)}
|
||||
className="w-full border border-zinc-600 rounded-lg px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadPreview}
|
||||
disabled={previewLoading}
|
||||
className="text-sm text-blue-400 hover:text-blue-700 font-medium"
|
||||
>
|
||||
{previewLoading ? "Counting..." : "Preview audience count"}
|
||||
</button>
|
||||
{preview && (
|
||||
<div className="rounded-lg bg-blue-900/30 border border-blue-200 px-4 py-3 text-sm">
|
||||
<span className="font-semibold text-blue-700">{preview.count}</span> customers match this audience
|
||||
{preview.sample_customers.length > 0 && (
|
||||
<div className="mt-2 text-xs text-blue-400">
|
||||
Sample: {preview.sample_customers.slice(0, 5).map((c) => c.email).join(", ")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Message content */}
|
||||
<div className="bg-zinc-900 rounded-xl border border-zinc-800 p-6 space-y-4">
|
||||
<h3 className="text-sm font-semibold text-slate-800">Message Content</h3>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300 mb-1">Subject</label>
|
||||
<input
|
||||
type="text"
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
className="w-full border border-zinc-600 rounded-lg px-3 py-2 text-sm"
|
||||
placeholder="Email subject line"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300 mb-1">Body (Plain Text)</label>
|
||||
<textarea
|
||||
value={bodyText}
|
||||
onChange={(e) => setBodyText(e.target.value)}
|
||||
rows={6}
|
||||
className="w-full border border-zinc-600 rounded-lg px-3 py-2 text-sm"
|
||||
placeholder="Message content..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scheduling */}
|
||||
<div className="bg-zinc-900 rounded-xl border border-zinc-800 p-6 space-y-4">
|
||||
<h3 className="text-sm font-semibold text-slate-800">Scheduling</h3>
|
||||
<div className="flex items-center gap-6">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="radio"
|
||||
checked={scheduleMode === "now"}
|
||||
onChange={() => setScheduleMode("now")}
|
||||
className="text-blue-400"
|
||||
/>
|
||||
<span className="text-zinc-300">Send immediately</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="radio"
|
||||
checked={scheduleMode === "later"}
|
||||
onChange={() => setScheduleMode("later")}
|
||||
className="text-blue-400"
|
||||
/>
|
||||
<span className="text-zinc-300">Schedule for later</span>
|
||||
</label>
|
||||
</div>
|
||||
{scheduleMode === "later" && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300 mb-1">Send date & time</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={scheduledAt}
|
||||
onChange={(e) => setScheduledAt(e.target.value)}
|
||||
className="border border-zinc-600 rounded-lg px-3 py-2 text-sm"
|
||||
min={new Date().toISOString().slice(0, 16)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={saving || !name || (scheduleMode === "later" && !scheduledAt)}
|
||||
className="inline-flex items-center rounded-lg bg-blue-600 px-5 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{saving ? "Saving..." : scheduleMode === "later" ? "Schedule Campaign" : "Save Draft"}
|
||||
</button>
|
||||
{campaign?.id && campaign.status === "draft" && scheduleMode === "now" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSend}
|
||||
disabled={sending}
|
||||
className="inline-flex items-center rounded-lg bg-green-600 px-5 py-2 text-sm font-medium text-white hover:bg-green-700 disabled:opacity-50"
|
||||
>
|
||||
{sending ? "Sending..." : "Send Campaign"}
|
||||
</button>
|
||||
)}
|
||||
{campaign?.id && scheduleMode === "later" && (
|
||||
<span className="text-sm text-blue-400 font-medium">
|
||||
Scheduled for {scheduledAt ? new Date(scheduledAt).toLocaleString() : "—"}
|
||||
</span>
|
||||
)}
|
||||
<a href="/admin/communications" className="text-sm text-zinc-500 hover:text-zinc-300 ml-4">
|
||||
Cancel
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useCart } from "@/context/CartContext";
|
||||
|
||||
type Props = { userId: string };
|
||||
|
||||
export default function CartHydration({ userId }: Props) {
|
||||
const { loadServerCart } = useCart();
|
||||
|
||||
useEffect(() => {
|
||||
if (!userId) return;
|
||||
loadServerCart(userId).catch(() => {});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [userId]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,621 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
getPlatformMetrics,
|
||||
getPlatformActivityFeed,
|
||||
getBrandHealthSnapshot,
|
||||
getPainLog,
|
||||
resolvePainLogItem,
|
||||
createPainLogItem,
|
||||
type PlatformMetrics,
|
||||
type ActivityEvent,
|
||||
type BrandHealth,
|
||||
type PainLogItem,
|
||||
} from "@/actions/platform/command-center";
|
||||
|
||||
// ── Constants ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const REFRESH_INTERVAL = 30000;
|
||||
|
||||
const EVENT_COLORS: Record<string, { bg: string; text: string; dot: string }> = {
|
||||
order_placed: { bg: "bg-violet-500/20", text: "text-violet-300", dot: "bg-violet-400" },
|
||||
order_completed: { bg: "bg-emerald-500/20", text: "text-emerald-300", dot: "bg-emerald-400" },
|
||||
pickup_completed: { bg: "bg-blue-900/300/20", text: "text-blue-300", dot: "bg-blue-400" },
|
||||
payment_failed: { bg: "bg-red-900/300/20", text: "text-red-300", dot: "bg-red-400" },
|
||||
stop_created: { bg: "bg-amber-900/300/20", text: "text-amber-300", dot: "bg-amber-400" },
|
||||
route_updated: { bg: "bg-cyan-500/20", text: "text-cyan-300", dot: "bg-cyan-400" },
|
||||
default: { bg: "bg-zinc-900/10", text: "text-white/60", dot: "bg-zinc-900/40" },
|
||||
};
|
||||
|
||||
const SEVERITY_CONFIG: Record<string, { bg: string; text: string; border: string }> = {
|
||||
critical: { bg: "bg-red-900/300/15", text: "text-red-300", border: "border-red-500/30" },
|
||||
high: { bg: "bg-orange-500/15",text: "text-orange-300", border: "border-orange-500/30" },
|
||||
medium: { bg: "bg-amber-900/300/15",text: "text-amber-300", border: "border-amber-500/30" },
|
||||
low: { bg: "bg-zinc-900/8", text: "text-white/60", border: "border-white/10" },
|
||||
};
|
||||
|
||||
const HEALTH_CONFIG: Record<string, { label: string; bg: string; text: string; border: string; dot: string }> = {
|
||||
healthy: { label: "Operational", bg: "bg-emerald-500/15", text: "text-emerald-300", border: "border-emerald-500/25", dot: "bg-emerald-400" },
|
||||
warning: { label: "Attention", bg: "bg-amber-900/300/15", text: "text-amber-300", border: "border-amber-500/25", dot: "bg-amber-400" },
|
||||
critical: { label: "Critical", bg: "bg-red-900/300/15", text: "text-red-300", border: "border-red-500/25", dot: "bg-red-400" },
|
||||
};
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function formatCurrency(n: number): string {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency", currency: "USD",
|
||||
minimumFractionDigits: 0, maximumFractionDigits: 0,
|
||||
}).format(n);
|
||||
}
|
||||
|
||||
function formatCurrencyFull(n: number): string {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency", currency: "USD",
|
||||
minimumFractionDigits: 2, maximumFractionDigits: 2,
|
||||
}).format(n);
|
||||
}
|
||||
|
||||
function timeAgo(dateStr: string): string {
|
||||
const diff = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000);
|
||||
if (diff < 60) return `${diff}s`;
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m`;
|
||||
if (diff < 86400)return `${Math.floor(diff / 3600)}h`;
|
||||
return `${Math.floor(diff / 86400)}d`;
|
||||
}
|
||||
|
||||
// ── Metric Card ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function MetricCard({
|
||||
label,
|
||||
value,
|
||||
sub,
|
||||
prefix,
|
||||
suffix,
|
||||
status,
|
||||
delay = 0,
|
||||
tv = false,
|
||||
}: {
|
||||
label: string;
|
||||
value: string | number;
|
||||
sub?: string;
|
||||
prefix?: string;
|
||||
suffix?: string;
|
||||
status?: "healthy" | "warning" | "critical";
|
||||
delay?: number;
|
||||
tv?: boolean;
|
||||
}) {
|
||||
const valueColor = {
|
||||
healthy: "text-white",
|
||||
warning: "text-amber-300",
|
||||
critical: "text-red-300",
|
||||
}[status ?? "healthy"]!;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
rounded-2xl border border-white/[0.06] bg-black/70 backdrop-blur-2xl p-5
|
||||
${status === "critical" ? "border-red-500/20" : ""}
|
||||
`}
|
||||
>
|
||||
<p className={`text-[10px] font-semibold uppercase tracking-[0.18em] text-white/25`}>{label}</p>
|
||||
<div className="mt-3 flex items-baseline gap-2">
|
||||
{prefix && <span className="text-2xl text-white/25">{prefix}</span>}
|
||||
<span className={`${tv ? "text-5xl" : "text-4xl"} font-black tracking-tighter leading-none ${valueColor}`}>
|
||||
{typeof value === "number" ? value.toLocaleString() : value}
|
||||
</span>
|
||||
{suffix && <span className="text-2xl text-white/25">{suffix}</span>}
|
||||
</div>
|
||||
{sub && <p className={`text-xs mt-2 text-white/20`}>{sub}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Brand Health Card ─────────────────────────────────────────────────────────
|
||||
|
||||
function BrandCard({ brand, tv = false }: { brand: BrandHealth; tv?: boolean }) {
|
||||
const c = HEALTH_CONFIG[brand.health_status as keyof typeof HEALTH_CONFIG] ?? HEALTH_CONFIG.warning;
|
||||
|
||||
return (
|
||||
<div className={`rounded-2xl border ${c.border} ${c.bg} p-6`}>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className={`font-bold text-white truncate leading-tight ${tv ? "text-2xl" : "text-lg"}`}>{brand.brand_name}</p>
|
||||
<p className={`text-sm text-white/20 mt-1`}>{brand.brand_slug}</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2 shrink-0">
|
||||
<span className={`rounded-full border px-3 py-1 text-xs font-bold uppercase tracking-wider ${c.bg} ${c.text} ${c.border}`}>
|
||||
{c.label}
|
||||
</span>
|
||||
<div className={`h-2 w-2 rounded-full ${c.dot}`} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`mt-6 grid grid-cols-3 gap-6`}>
|
||||
{[
|
||||
{ label: "Orders", value: brand.orders_today },
|
||||
{ label: "Revenue", value: formatCurrency(Number(brand.revenue_today)) },
|
||||
{ label: "Routes", value: brand.active_stops },
|
||||
].map(({ label, value }) => (
|
||||
<div key={label} className="text-center">
|
||||
<p className={`text-xs font-medium text-white/20`}>{label}</p>
|
||||
<p className={`${tv ? "text-2xl" : "text-xl"} font-black text-white mt-1 leading-none`}>{value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{brand.open_pain_items > 0 && (
|
||||
<div className="mt-4 flex items-center gap-2 rounded-xl bg-red-900/300/10 border border-red-500/20 px-3 py-2.5">
|
||||
<span className="h-2 w-2 rounded-full bg-red-400" />
|
||||
<span className={`text-sm font-semibold text-red-300`}>
|
||||
{brand.open_pain_items} open issue{brand.open_pain_items !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Activity Item ──────────────────────────────────────────────────────────────
|
||||
|
||||
function ActivityItem({ event }: { event: ActivityEvent }) {
|
||||
const c = EVENT_COLORS[event.event_type] ?? EVENT_COLORS.default;
|
||||
const label = event.event_type.replace(/_/g, " ").replace(/\b\w/g, l => l.toUpperCase());
|
||||
|
||||
return (
|
||||
<div className="flex items-start gap-3.5 py-3.5 border-b border-white/[0.03] last:border-0">
|
||||
<div className="mt-1.5 flex-shrink-0">
|
||||
<span className={`block h-2 w-2 rounded-full ${c.dot}`} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className={`rounded-md px-2 py-0.5 text-xs font-semibold ${c.bg} ${c.text}`}>
|
||||
{label}
|
||||
</span>
|
||||
{event.brand_name && (
|
||||
<span className={`text-xs font-medium text-white/25`}>{event.brand_name}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-3">
|
||||
<p className={`text-sm text-white/40 truncate`}>
|
||||
{event.entity_type && <span>{event.entity_type}</span>}
|
||||
{event.entity_id && <span className="font-mono text-white/15 ml-1">#{event.entity_id.slice(0, 6)}</span>}
|
||||
</p>
|
||||
<span className={`text-xs text-white/15 shrink-0`}>
|
||||
{event.created_at ? timeAgo(event.created_at) : ""}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Pain Item ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function PainItem({
|
||||
item,
|
||||
onResolve,
|
||||
onSnooze,
|
||||
}: {
|
||||
item: PainLogItem;
|
||||
onResolve: (id: string) => void;
|
||||
onSnooze: (id: string) => void;
|
||||
}) {
|
||||
const c = SEVERITY_CONFIG[item.severity] ?? SEVERITY_CONFIG.low;
|
||||
|
||||
return (
|
||||
<div className={`rounded-xl border ${c.border} ${c.bg} p-4`}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-2.5 flex-wrap min-w-0">
|
||||
<span className={`rounded-full border px-2.5 py-0.5 text-xs font-bold uppercase tracking-wider ${c.bg} ${c.text} ${c.border}`}>
|
||||
{item.severity}
|
||||
</span>
|
||||
<span className={`text-sm font-semibold text-white/80 leading-tight`}>{item.title}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<button
|
||||
onClick={() => onSnooze(item.id)}
|
||||
className="rounded-lg bg-zinc-900/5 border border-white/10 px-2.5 py-1 text-xs font-medium text-white/35 hover:bg-zinc-900/10 hover:text-white/60 transition-all"
|
||||
>
|
||||
Snooze
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onResolve(item.id)}
|
||||
className="rounded-lg bg-emerald-500/20 border border-emerald-500/30 px-2.5 py-1 text-xs font-medium text-emerald-300 hover:bg-emerald-500/30 transition-all"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`mt-2 flex items-center gap-2 text-xs text-white/25`}>
|
||||
{item.brand_name && <span>{item.brand_name}</span>}
|
||||
<span>·</span>
|
||||
<span>{item.category}</span>
|
||||
<span>·</span>
|
||||
<span>{item.created_at ? timeAgo(item.created_at) + " ago" : ""}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── AI Briefing ────────────────────────────────────────────────────────────────
|
||||
|
||||
function AIBriefing({ metrics, brandHealth }: {
|
||||
metrics: PlatformMetrics | null;
|
||||
brandHealth: BrandHealth[];
|
||||
}) {
|
||||
const lines: string[] = [];
|
||||
if (!metrics) return <div className="h-16 animate-pulse bg-zinc-900/[0.04] rounded-xl" />;
|
||||
|
||||
if (metrics.orders_today > 0) {
|
||||
lines.push(`${metrics.orders_today} order${metrics.orders_today !== 1 ? "s" : ""} placed today.`);
|
||||
}
|
||||
if (Number(metrics.revenue_today) > 0) {
|
||||
lines.push(`${formatCurrencyFull(Number(metrics.revenue_today))} in revenue processed.`);
|
||||
}
|
||||
if (metrics.failed_orders_today > 0) {
|
||||
lines.push(`${metrics.failed_orders_today} failed order${metrics.failed_orders_today !== 1 ? "s" : ""} need attention.`);
|
||||
}
|
||||
if (metrics.pending_orders_today > 0) {
|
||||
lines.push(`${metrics.pending_orders_today} order${metrics.pending_orders_today !== 1 ? "s" : ""} pending confirmation.`);
|
||||
}
|
||||
const crit = brandHealth.filter(b => b.health_status === "critical").length;
|
||||
if (crit > 0) lines.push(`${crit} brand${crit !== 1 ? "s" : ""} in critical state.`);
|
||||
const warn = brandHealth.filter(b => b.health_status === "warning").length;
|
||||
if (warn > 0 && crit === 0) lines.push(`${warn} brand${warn !== 1 ? "s" : ""} need attention.`);
|
||||
|
||||
if (lines.length === 0) {
|
||||
lines.push("Platform is running smoothly — no active issues.");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{lines.map((line, i) => (
|
||||
<p key={i} className="text-sm text-white/50 leading-relaxed">
|
||||
{line}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function CommandCenterDashboard() {
|
||||
const [metrics, setMetrics] = useState<PlatformMetrics | null>(null);
|
||||
const [activity, setActivity] = useState<ActivityEvent[]>([]);
|
||||
const [brandHealth, setBrandHealth] = useState<BrandHealth[]>([]);
|
||||
const [painLog, setPainLog] = useState<PainLogItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [resolving, setResolving] = useState<Set<string>>(new Set());
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [tvMode, setTvMode] = useState(false);
|
||||
const [addForm, setAddForm] = useState({ severity: "medium", category: "operations", title: "", description: "" });
|
||||
const [snoozed, setSnoozed] = useState<Set<string>>(new Set());
|
||||
|
||||
const tv = tvMode;
|
||||
|
||||
const fetchAll = useCallback(async () => {
|
||||
try {
|
||||
const [m, a, b, p] = await Promise.all([
|
||||
getPlatformMetrics(),
|
||||
getPlatformActivityFeed(),
|
||||
getBrandHealthSnapshot(),
|
||||
getPainLog(),
|
||||
]);
|
||||
setMetrics(m);
|
||||
setActivity(a);
|
||||
setBrandHealth(b);
|
||||
setPainLog(p);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAll();
|
||||
const iv = setInterval(fetchAll, REFRESH_INTERVAL);
|
||||
return () => clearInterval(iv);
|
||||
}, [fetchAll]);
|
||||
|
||||
const handleResolve = async (id: string) => {
|
||||
setResolving(prev => new Set([...prev, id]));
|
||||
try {
|
||||
const r = await resolvePainLogItem(id);
|
||||
if (r.success) setPainLog(prev => prev.filter(i => i.id !== id));
|
||||
} finally {
|
||||
setResolving(prev => { const n = new Set(prev); n.delete(id); return n; });
|
||||
}
|
||||
};
|
||||
|
||||
const handleSnooze = (id: string) => {
|
||||
setSnoozed(prev => new Set([...prev, id]));
|
||||
setTimeout(() => {
|
||||
setSnoozed(prev => { const n = new Set(prev); n.delete(id); return n; });
|
||||
}, 3600000);
|
||||
};
|
||||
|
||||
const handleAdd = async () => {
|
||||
if (!addForm.title.trim()) return;
|
||||
const r = await createPainLogItem({
|
||||
severity: addForm.severity as "low" | "medium" | "high" | "critical",
|
||||
category: addForm.category,
|
||||
title: addForm.title,
|
||||
description: addForm.description || null,
|
||||
});
|
||||
if (r.success) {
|
||||
setAddForm({ severity: "medium", category: "operations", title: "", description: "" });
|
||||
setShowAddForm(false);
|
||||
fetchAll();
|
||||
}
|
||||
};
|
||||
|
||||
const openItems = painLog.filter(p => p.status === "open" && !snoozed.has(p.id));
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-6">
|
||||
<div className="h-14 w-14 rounded-full border-2 border-white/[0.08] border-t-white/25 animate-spin" />
|
||||
<p className="text-xs text-white/25 tracking-widest uppercase">Initializing</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="rounded-2xl border border-white/[0.06] bg-black/70 backdrop-blur-2xl p-10 text-center max-w-md">
|
||||
<div className="mb-5 text-5xl">⚠</div>
|
||||
<h3 className="text-xl font-bold text-white">Connection Lost</h3>
|
||||
<p className="text-sm text-white/35 mt-2">{error}</p>
|
||||
<button
|
||||
onClick={fetchAll}
|
||||
className="mt-8 rounded-2xl bg-zinc-900/10 border border-white/20 px-8 py-3 text-sm font-semibold text-white hover:bg-zinc-900/20 transition-all"
|
||||
>
|
||||
Reconnect
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
|
||||
{/* ── Header ─────────────────────────────────────────────────────────── */}
|
||||
<div className="mb-10 flex items-center justify-between px-8">
|
||||
<div>
|
||||
<h1 className={`${tv ? "text-4xl" : "text-3xl"} font-black tracking-tight text-white`}>
|
||||
Command Center
|
||||
</h1>
|
||||
<p className={`${tv ? "text-base" : "text-sm"} text-white/25 mt-2 tracking-wide`}>
|
||||
{new Date().toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric", year: "numeric" })}
|
||||
{" · "}
|
||||
<span className="text-emerald-400/50 font-semibold">LIVE</span>
|
||||
<span className="ml-2 inline-block h-2 w-2 rounded-full bg-emerald-400 animate-pulse" />
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={fetchAll}
|
||||
className="flex items-center gap-2.5 rounded-xl bg-zinc-900/5 border border-white/10 px-4 py-2 text-sm text-white/35 hover:bg-zinc-900/10 hover:text-white/60 transition-all"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
Refresh
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTvMode(v => !v)}
|
||||
className={`flex items-center gap-2 rounded-xl px-4 py-2 text-sm font-medium transition-all ${
|
||||
tv
|
||||
? "bg-zinc-900/15 border border-white/25 text-white/80"
|
||||
: "bg-zinc-900/5 border border-white/10 text-white/35 hover:text-white/60"
|
||||
}`}
|
||||
>
|
||||
{tv ? "Exit TV" : "TV Mode"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── KPI Row ──────────────────────────────────────────────────────────── */}
|
||||
<div className={`grid ${tv ? "grid-cols-3" : "grid-cols-2 lg:grid-cols-6"} gap-5 px-8 mb-12`}>
|
||||
<MetricCard label="Active Brands" value={metrics?.active_brands ?? 0} status="healthy" />
|
||||
<MetricCard label="Orders Today" value={metrics?.orders_today ?? 0} status={metrics?.failed_orders_today ? "critical" : "healthy"} />
|
||||
<MetricCard label="Revenue Today" value={formatCurrency(Number(metrics?.revenue_today ?? 0))} status="healthy" />
|
||||
<MetricCard label="Active Routes" value={metrics?.active_routes ?? 0} status={(metrics?.pending_orders_today ?? 0) > 5 ? "warning" : "healthy"} />
|
||||
<MetricCard label="Failed Orders" value={metrics?.failed_orders_today ?? 0} status={(metrics?.failed_orders_today ?? 0) > 0 ? "critical" : "healthy"} />
|
||||
<MetricCard label="Pending Orders" value={metrics?.pending_orders_today ?? 0} status={(metrics?.pending_orders_today ?? 0) > 5 ? "warning" : "healthy"} />
|
||||
</div>
|
||||
|
||||
{/* ── AI Briefing (full width) ──────────────────────────────────────── */}
|
||||
<div className="px-8 mb-10">
|
||||
<div className="rounded-2xl border border-white/[0.06] bg-black/70 backdrop-blur-2xl p-8">
|
||||
<div className="flex items-center gap-4 mb-6 pb-6 border-b border-white/[0.04]">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-violet-500/15 border border-violet-500/20">
|
||||
<span className="text-lg">🤖</span>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xs font-bold uppercase tracking-[0.22em] text-white/40">Daily Briefing</h2>
|
||||
<p className="text-xs text-white/20 mt-0.5">Platform intelligence summary</p>
|
||||
</div>
|
||||
</div>
|
||||
<AIBriefing metrics={metrics} brandHealth={brandHealth} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 2-Column Body ───────────────────────────────────────────────────── */}
|
||||
<div className={`grid gap-10 px-8 ${tv ? "grid-cols-12" : "grid-cols-1 lg:grid-cols-2"}`}>
|
||||
|
||||
{/* ── LEFT: Brand Health + Activity ───────────────────────────── */}
|
||||
<div className="space-y-10">
|
||||
{/* Brand Health */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<h2 className="text-xs font-bold uppercase tracking-[0.28em] text-white/25">
|
||||
Brand Health
|
||||
</h2>
|
||||
<span className="rounded-full bg-zinc-900/5 px-2.5 py-0.5 text-xs text-white/15">
|
||||
{brandHealth.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-5">
|
||||
{brandHealth.length === 0 ? (
|
||||
<div className="rounded-2xl border border-white/[0.06] bg-black/70 backdrop-blur-2xl p-10 text-center">
|
||||
<p className="text-sm text-white/25">No active brands</p>
|
||||
</div>
|
||||
) : (
|
||||
brandHealth.map((b) => (
|
||||
<BrandCard key={b.brand_id} brand={b} tv={tv} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Activity Feed */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<h2 className="text-xs font-bold uppercase tracking-[0.28em] text-white/25">
|
||||
Live Activity
|
||||
</h2>
|
||||
<span className="rounded-full bg-zinc-900/5 px-2.5 py-0.5 text-xs text-white/15">
|
||||
{activity.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/[0.06] bg-black/70 backdrop-blur-2xl overflow-hidden">
|
||||
<div className="max-h-[420px] overflow-y-auto p-5">
|
||||
{activity.length === 0 ? (
|
||||
<div className="py-12 text-center">
|
||||
<p className="text-sm text-white/20">No recent events</p>
|
||||
</div>
|
||||
) : (
|
||||
activity.slice(0, 30).map((e) => (
|
||||
<ActivityItem key={e.id} event={e} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── RIGHT: Founder Queue ─────────────────────────────── */}
|
||||
<div className="space-y-10">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-xs font-bold uppercase tracking-[0.28em] text-white/25">
|
||||
Founder Queue
|
||||
</h2>
|
||||
{openItems.length > 0 && (
|
||||
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-amber-900/300/20 text-xs font-black text-amber-300">
|
||||
{openItems.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowAddForm(v => !v)}
|
||||
className="text-xs text-white/25 hover:text-white/50 transition-colors"
|
||||
>
|
||||
+ Add
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showAddForm && (
|
||||
<div className="mb-5 space-y-2.5 rounded-2xl border border-white/[0.06] bg-black/70 backdrop-blur-2xl p-5">
|
||||
<select
|
||||
value={addForm.severity}
|
||||
onChange={e => setAddForm(f => ({ ...f, severity: e.target.value }))}
|
||||
className="w-full rounded-xl bg-zinc-900/5 border border-white/10 px-4 py-3 text-sm text-white/75 outline-none focus:border-white/20"
|
||||
>
|
||||
<option value="low">Low</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="high">High</option>
|
||||
<option value="critical">Critical</option>
|
||||
</select>
|
||||
<input
|
||||
value={addForm.title}
|
||||
onChange={e => setAddForm(f => ({ ...f, title: e.target.value }))}
|
||||
placeholder="Issue title..."
|
||||
className="w-full rounded-xl bg-zinc-900/5 border border-white/10 px-4 py-3 text-sm text-white/75 placeholder-white/15 outline-none focus:border-white/20"
|
||||
/>
|
||||
<input
|
||||
value={addForm.category}
|
||||
onChange={e => setAddForm(f => ({ ...f, category: e.target.value }))}
|
||||
placeholder="Category (e.g. deployment, payment)"
|
||||
className="w-full rounded-xl bg-zinc-900/5 border border-white/10 px-4 py-3 text-sm text-white/75 placeholder-white/15 outline-none focus:border-white/20"
|
||||
/>
|
||||
<textarea
|
||||
value={addForm.description}
|
||||
onChange={e => setAddForm(f => ({ ...f, description: e.target.value }))}
|
||||
placeholder="Description (optional)"
|
||||
rows={2}
|
||||
className="w-full resize-none rounded-xl bg-zinc-900/5 border border-white/10 px-4 py-3 text-sm text-white/75 placeholder-white/15 outline-none focus:border-white/20"
|
||||
/>
|
||||
<div className="flex gap-2.5">
|
||||
<button onClick={handleAdd} className="flex-1 rounded-xl bg-emerald-500/20 border border-emerald-500/30 px-4 py-2.5 text-sm font-semibold text-emerald-300 hover:bg-emerald-500/30 transition-all">
|
||||
Log Issue
|
||||
</button>
|
||||
<button onClick={() => setShowAddForm(false)} className="flex-1 rounded-xl bg-zinc-900/5 border border-white/10 px-4 py-2.5 text-sm font-medium text-white/35 hover:bg-zinc-900/10 transition-all">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
{openItems.length === 0 ? (
|
||||
<div className="rounded-2xl border border-white/[0.06] bg-black/70 backdrop-blur-2xl p-10 text-center">
|
||||
<p className="text-base font-semibold text-white/35">All clear</p>
|
||||
<p className="text-sm text-white/20 mt-1.5">Add an issue to track it here.</p>
|
||||
</div>
|
||||
) : (
|
||||
openItems.map((item) => (
|
||||
<PainItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
onResolve={handleResolve}
|
||||
onSnooze={handleSnooze}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Access */}
|
||||
<div>
|
||||
<h2 className="text-xs font-bold uppercase tracking-[0.28em] text-white/25 mb-5">
|
||||
Quick Access
|
||||
</h2>
|
||||
<div className="rounded-2xl border border-white/[0.06] bg-black/70 backdrop-blur-2xl p-4 space-y-1">
|
||||
{[
|
||||
{ label: "Reports", href: "/admin/reports" },
|
||||
{ label: "Orders", href: "/admin/orders" },
|
||||
{ label: "Stops", href: "/admin/stops" },
|
||||
{ label: "Settings", href: "/admin/settings" },
|
||||
].map(link => (
|
||||
<a
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className="flex items-center justify-between rounded-xl px-4 py-3.5 text-sm text-white/30 hover:bg-zinc-900/5 hover:text-white/70 transition-all"
|
||||
>
|
||||
<span className="font-medium">{link.label}</span>
|
||||
<span className="text-white/10">→</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-24" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { CommunicationSettings } from "@/actions/communications/settings";
|
||||
import { upsertCommunicationSettings } from "@/actions/communications/settings";
|
||||
|
||||
const BRAND_ID = process.env.NEXT_PUBLIC_TUXEDO_BRAND_ID ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
|
||||
export default function CommunicationSettingsForm({
|
||||
settings,
|
||||
brandId,
|
||||
}: {
|
||||
settings: CommunicationSettings | null;
|
||||
brandId: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [senderEmail, setSenderEmail] = useState(settings?.default_sender_email ?? "");
|
||||
const [senderName, setSenderName] = useState(settings?.default_sender_name ?? "");
|
||||
const [replyTo, setReplyTo] = useState(settings?.reply_to_email ?? "");
|
||||
const [footerHtml, setFooterHtml] = useState(settings?.email_footer_html ?? "");
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
setError("");
|
||||
setSuccess(false);
|
||||
const result = await upsertCommunicationSettings({
|
||||
brand_id: brandId,
|
||||
sender_email: senderEmail,
|
||||
sender_name: senderName,
|
||||
reply_to_email: replyTo,
|
||||
provider: "resend",
|
||||
footer_html: footerHtml,
|
||||
});
|
||||
setSaving(false);
|
||||
if (!result.success) {
|
||||
setError(result.error ?? "Failed to save");
|
||||
return;
|
||||
}
|
||||
setSuccess(true);
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-xl font-semibold text-zinc-100">Sender Settings</h2>
|
||||
<p className="text-sm text-zinc-500 mt-1">
|
||||
Configure the default sender identity for this brand's email campaigns.
|
||||
Provider (Resend) is configured via environment variables — no credentials stored here.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded-lg bg-red-900/30 border border-red-200 px-4 py-3 text-sm text-red-400">{error}</div>
|
||||
)}
|
||||
{success && (
|
||||
<div className="mb-4 rounded-lg bg-green-900/30 border border-green-200 px-4 py-3 text-sm text-green-400">
|
||||
Settings saved successfully.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-zinc-900 rounded-xl border border-zinc-800 p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300 mb-1">Default Sender Email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={senderEmail}
|
||||
onChange={(e) => setSenderEmail(e.target.value)}
|
||||
className="w-full border border-zinc-600 rounded-lg px-3 py-2 text-sm"
|
||||
placeholder="orders@tuxedocorn.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300 mb-1">Default Sender Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={senderName}
|
||||
onChange={(e) => setSenderName(e.target.value)}
|
||||
className="w-full border border-zinc-600 rounded-lg px-3 py-2 text-sm"
|
||||
placeholder="Route Commerce"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300 mb-1">Reply-To Email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={replyTo}
|
||||
onChange={(e) => setReplyTo(e.target.value)}
|
||||
className="w-full border border-zinc-600 rounded-lg px-3 py-2 text-sm"
|
||||
placeholder="support@tuxedocorn.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300 mb-1">Email Footer HTML (optional)</label>
|
||||
<textarea
|
||||
value={footerHtml}
|
||||
onChange={(e) => setFooterHtml(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full border border-zinc-600 rounded-lg px-3 py-2 text-sm font-mono text-xs"
|
||||
placeholder="<p>Unsubscribe: <a href='...'>link</a></p>"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-slate-400">Include your unsubscribe link here. Add {`{unsubscribe_url}`} as placeholder.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="inline-flex items-center rounded-lg bg-blue-600 px-5 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{saving ? "Saving..." : "Save Settings"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
const TABS = [
|
||||
{ id: "campaigns", label: "Campaigns", href: "/admin/communications" },
|
||||
{ id: "compose", label: "Compose", href: "/admin/communications/compose" },
|
||||
{ id: "segments", label: "Segments", href: "/admin/communications/segments" },
|
||||
{ id: "analytics", label: "Analytics", href: "/admin/communications/analytics" },
|
||||
{ id: "templates", label: "Templates", href: "/admin/communications/templates" },
|
||||
{ id: "contacts", label: "Contacts", href: "/admin/communications/contacts" },
|
||||
{ id: "logs", label: "Message Logs", href: "/admin/communications/logs" },
|
||||
{ id: "settings", label: "Settings", href: "/admin/communications/settings" },
|
||||
];
|
||||
|
||||
export default function CommunicationsNav({
|
||||
activeTab,
|
||||
}: {
|
||||
activeTab: "campaigns" | "templates" | "contacts" | "logs" | "settings" | "segments" | "analytics" | "compose";
|
||||
}) {
|
||||
return (
|
||||
<div className="border-b border-zinc-800 mb-6">
|
||||
<nav className="flex gap-1 -mb-px">
|
||||
{TABS.map((tab) => {
|
||||
const isActive = tab.id === activeTab;
|
||||
return (
|
||||
<a
|
||||
key={tab.id}
|
||||
href={tab.href}
|
||||
className={`px-1 py-2.5 text-sm font-medium border-b-2 transition-colors whitespace-nowrap ${
|
||||
isActive
|
||||
? "border-blue-600 text-blue-400"
|
||||
: "border-transparent text-zinc-500 hover:text-zinc-300 hover:border-zinc-600"
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { TABS };
|
||||
@@ -0,0 +1,115 @@
|
||||
"use client";
|
||||
|
||||
import CommunicationsNav from "./CommunicationsNav";
|
||||
import CampaignListPanel, { CampaignEditPanel } from "./CampaignListPanel";
|
||||
import { TemplateListPanel, TemplateEditForm } from "./TemplateEditForm";
|
||||
import MessageLogPanel from "./MessageLogPanel";
|
||||
import CommunicationSettingsForm from "./CommunicationSettingsForm";
|
||||
import ContactListPanel from "./ContactListPanel";
|
||||
import ContactImportForm from "./ContactImportForm";
|
||||
import SegmentBuilderPage from "@/components/admin/HarvestReach/SegmentBuilderPage";
|
||||
import AnalyticsDashboard from "@/components/admin/HarvestReach/AnalyticsDashboard";
|
||||
import CampaignComposerPage from "@/components/admin/HarvestReach/CampaignComposerPage";
|
||||
import type { Campaign } from "@/actions/communications/campaigns";
|
||||
import type { Template } from "@/actions/communications/templates";
|
||||
import type { MessageLogEntry } from "@/actions/communications/send";
|
||||
import type { CommunicationSettings } from "@/actions/communications/settings";
|
||||
import type { Contact } from "@/actions/communications/contacts";
|
||||
import type { Segment } from "@/actions/harvest-reach/segments";
|
||||
import type { CampaignAnalytics } from "@/actions/harvest-reach/campaigns";
|
||||
|
||||
export default function CommunicationsPage({
|
||||
campaigns,
|
||||
templates,
|
||||
activeTab,
|
||||
brandId,
|
||||
editCampaign,
|
||||
editMode,
|
||||
editTemplate,
|
||||
initialLogs = [],
|
||||
initialSettings = null,
|
||||
initialContacts = [],
|
||||
initialContactTotal = 0,
|
||||
initialSegments = [],
|
||||
initialAnalytics = [],
|
||||
editCampaignId,
|
||||
}: {
|
||||
campaigns: Campaign[];
|
||||
templates: Template[];
|
||||
activeTab: "campaigns" | "templates" | "contacts" | "logs" | "settings" | "segments" | "analytics" | "compose";
|
||||
brandId: string;
|
||||
editCampaign?: Campaign | null;
|
||||
editMode?: "edit" | "new";
|
||||
editTemplate?: Template | null;
|
||||
initialLogs?: MessageLogEntry[];
|
||||
initialSettings?: CommunicationSettings | null;
|
||||
initialContacts?: Contact[];
|
||||
initialContactTotal?: number;
|
||||
initialSegments?: Segment[];
|
||||
initialAnalytics?: CampaignAnalytics[];
|
||||
editCampaignId?: string;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<CommunicationsNav activeTab={activeTab} />
|
||||
|
||||
{activeTab === "campaigns" && (
|
||||
editCampaign !== undefined || editMode === "new" ? (
|
||||
<CampaignEditPanel
|
||||
campaign={editCampaign ?? undefined}
|
||||
templates={templates}
|
||||
mode={editMode ?? "edit"}
|
||||
brandId={brandId}
|
||||
/>
|
||||
) : (
|
||||
<CampaignListPanel initialCampaigns={campaigns} />
|
||||
)
|
||||
)}
|
||||
|
||||
{activeTab === "templates" && (
|
||||
editTemplate !== undefined || editMode === "new" ? (
|
||||
<TemplateEditForm
|
||||
template={editTemplate ?? undefined}
|
||||
mode={editMode ?? "edit"}
|
||||
brandId={brandId}
|
||||
/>
|
||||
) : (
|
||||
<TemplateListPanel templates={templates} />
|
||||
)
|
||||
)}
|
||||
|
||||
{activeTab === "contacts" && (
|
||||
<div className="space-y-6">
|
||||
<ContactListPanel initialContacts={initialContacts} initialTotal={initialContactTotal} brandId={brandId} />
|
||||
<ContactImportForm brandId={brandId} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "logs" && (
|
||||
<MessageLogPanel initialLogs={initialLogs} />
|
||||
)}
|
||||
|
||||
{activeTab === "settings" && (
|
||||
<CommunicationSettingsForm settings={initialSettings} brandId={brandId} />
|
||||
)}
|
||||
|
||||
{activeTab === "segments" && (
|
||||
<SegmentBuilderPage brandId={brandId} initialSegments={initialSegments} />
|
||||
)}
|
||||
|
||||
{activeTab === "analytics" && (
|
||||
<AnalyticsDashboard analytics={initialAnalytics} />
|
||||
)}
|
||||
|
||||
{(activeTab === "compose" || (activeTab === "campaigns" && editCampaignId)) && (
|
||||
<CampaignComposerPage
|
||||
brandId={brandId}
|
||||
campaigns={campaigns}
|
||||
templates={templates}
|
||||
segments={initialSegments}
|
||||
editCampaignId={editCampaignId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,621 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useCallback } from "react";
|
||||
import {
|
||||
importContactsBatch,
|
||||
previewContactImport,
|
||||
type ContactImportEntry,
|
||||
type ImportPreviewResult,
|
||||
type ColumnMapping,
|
||||
type ImportField,
|
||||
} from "@/actions/communications/contacts";
|
||||
|
||||
type Step = "idle" | "preview" | "confirming" | "importing" | "result";
|
||||
|
||||
type ImportResult = {
|
||||
created: number;
|
||||
updated: number;
|
||||
skipped: number;
|
||||
errors: { row: ContactImportEntry; error: string }[];
|
||||
};
|
||||
|
||||
const LARGE_FILE_ROWS = 10_000;
|
||||
|
||||
const FIELD_LABELS: Record<string, string> = {
|
||||
email: "Email",
|
||||
phone: "Phone",
|
||||
first_name: "First Name",
|
||||
last_name: "Last Name",
|
||||
full_name: "Full Name",
|
||||
tags: "Tags",
|
||||
email_opt_in: "Email Opt-In",
|
||||
sms_opt_in: "SMS Opt-In",
|
||||
external_id: "External ID",
|
||||
};
|
||||
|
||||
export default function ContactImportForm({ brandId }: { brandId: string }) {
|
||||
const [step, setStep] = useState<Step>("idle");
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [preview, setPreview] = useState<ImportPreviewResult | null>(null);
|
||||
const [rawHeaders, setRawHeaders] = useState<string[]>([]);
|
||||
const [rawRows, setRawRows] = useState<string[][]>([]);
|
||||
const [importRows, setImportRows] = useState<ContactImportEntry[]>([]);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [result, setResult] = useState<ImportResult | null>(null);
|
||||
const [globalError, setGlobalError] = useState("");
|
||||
const [overridingMappings, setOverridingMappings] = useState<
|
||||
Record<string, ImportField>
|
||||
>({});
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// ── CSV parsing + preview ──────────────────────────────────────────────────
|
||||
|
||||
const handleFile = useCallback(
|
||||
async (f: File) => {
|
||||
setFile(f);
|
||||
setResult(null);
|
||||
setGlobalError("");
|
||||
setOverridingMappings({});
|
||||
|
||||
const text = await f.text();
|
||||
const previewResult = await previewContactImport(text);
|
||||
|
||||
if (!previewResult.success) {
|
||||
setGlobalError(previewResult.error);
|
||||
setStep("idle");
|
||||
return;
|
||||
}
|
||||
|
||||
const { csv, totalRows } = await import("@/lib/csv-parser").then(
|
||||
(m) => m.parseCSVWithLimits(text)
|
||||
);
|
||||
|
||||
setRawHeaders(csv.headers);
|
||||
setRawRows(csv.rows);
|
||||
setPreview(previewResult.preview);
|
||||
setStep("preview");
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
const f = e.dataTransfer.files[0];
|
||||
if (f && f.name.endsWith(".csv")) handleFile(f);
|
||||
else setGlobalError("Please upload a .csv file");
|
||||
},
|
||||
[handleFile]
|
||||
);
|
||||
|
||||
const handleFileChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) handleFile(f);
|
||||
},
|
||||
[handleFile]
|
||||
);
|
||||
|
||||
// ── Mapping override ───────────────────────────────────────────────────────
|
||||
|
||||
function applyMappings(
|
||||
mappings: ColumnMapping[],
|
||||
overrides: Record<string, ImportField>,
|
||||
headers: string[],
|
||||
rows: string[][]
|
||||
): ContactImportEntry[] {
|
||||
const effective = mappings.map((m) => ({
|
||||
...m,
|
||||
field: overrides[m.csvColumn] ?? m.field,
|
||||
}));
|
||||
|
||||
const emailColIdx = effective.findIndex((m) => m.field === "email");
|
||||
const phoneColIdx = effective.findIndex((m) => m.field === "phone");
|
||||
|
||||
const seenEmails = new Set<string>();
|
||||
const seenPhones = new Set<string>();
|
||||
const entries: ContactImportEntry[] = [];
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i];
|
||||
const entry: ContactImportEntry = {};
|
||||
const ignored: Record<string, string> = {};
|
||||
|
||||
for (const mapping of effective) {
|
||||
const colIdx = headers.indexOf(mapping.csvColumn);
|
||||
if (colIdx === -1) continue;
|
||||
const raw = row[colIdx] ?? "";
|
||||
|
||||
if (mapping.field === null) {
|
||||
if (raw) ignored[mapping.csvColumn] = raw;
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (mapping.field) {
|
||||
case "email": {
|
||||
const cleaned = raw.trim().toLowerCase();
|
||||
entry.email = cleaned || undefined;
|
||||
break;
|
||||
}
|
||||
case "phone": {
|
||||
const stripped = raw.replace(/[\s\-().[\]]/g, "");
|
||||
entry.phone = raw.trim() || undefined;
|
||||
break;
|
||||
}
|
||||
case "first_name":
|
||||
entry.first_name = raw || undefined;
|
||||
break;
|
||||
case "last_name":
|
||||
entry.last_name = raw || undefined;
|
||||
break;
|
||||
case "full_name":
|
||||
entry.full_name = raw || undefined;
|
||||
break;
|
||||
case "tags":
|
||||
entry.tags = raw
|
||||
? raw.split(/[;:,]/).map((t) => t.trim()).filter(Boolean)
|
||||
: undefined;
|
||||
break;
|
||||
case "email_opt_in":
|
||||
entry.email_opt_in =
|
||||
raw === "true" ||
|
||||
raw === "1" ||
|
||||
raw === "yes" ||
|
||||
raw === "y" ||
|
||||
raw === "on";
|
||||
break;
|
||||
case "sms_opt_in":
|
||||
entry.sms_opt_in =
|
||||
raw === "true" ||
|
||||
raw === "1" ||
|
||||
raw === "yes" ||
|
||||
raw === "y" ||
|
||||
raw === "on";
|
||||
break;
|
||||
case "external_id":
|
||||
entry.external_id = raw || undefined;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const hasEmail = !!entry.email;
|
||||
const hasPhone = !!entry.phone;
|
||||
if (!hasEmail && !hasPhone) continue;
|
||||
|
||||
// Dedupe within file
|
||||
let isDup = false;
|
||||
if (entry.email) {
|
||||
if (seenEmails.has(entry.email!)) isDup = true;
|
||||
seenEmails.add(entry.email!);
|
||||
}
|
||||
if (entry.phone && !isDup) {
|
||||
if (seenPhones.has(entry.phone)) isDup = true;
|
||||
seenPhones.add(entry.phone);
|
||||
}
|
||||
if (isDup) continue;
|
||||
|
||||
if (Object.keys(ignored).length > 0) {
|
||||
entry._metadata = ignored;
|
||||
}
|
||||
entries.push(entry);
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
// ── Confirm and import ─────────────────────────────────────────────────────
|
||||
|
||||
const handleConfirm = useCallback(
|
||||
async () => {
|
||||
if (!preview || rawRows.length === 0) return;
|
||||
setStep("importing");
|
||||
|
||||
const rowsToImport = applyMappings(
|
||||
preview.mappings,
|
||||
overridingMappings,
|
||||
rawHeaders,
|
||||
rawRows
|
||||
);
|
||||
|
||||
setImportRows(rowsToImport);
|
||||
|
||||
setImporting(true);
|
||||
const importResult = await importContactsBatch({
|
||||
brandId,
|
||||
contacts: rowsToImport,
|
||||
});
|
||||
setImporting(false);
|
||||
|
||||
if (importResult.success) {
|
||||
setResult(importResult.result);
|
||||
setStep("result");
|
||||
} else {
|
||||
setGlobalError(importResult.error ?? "Import failed");
|
||||
setStep("result");
|
||||
}
|
||||
},
|
||||
[brandId, preview, overridingMappings, rawHeaders, rawRows]
|
||||
);
|
||||
|
||||
const handleReset = () => {
|
||||
setStep("idle");
|
||||
setFile(null);
|
||||
setPreview(null);
|
||||
setImportRows([]);
|
||||
setResult(null);
|
||||
setGlobalError("");
|
||||
setOverridingMappings({});
|
||||
if (fileRef.current) fileRef.current.value = "";
|
||||
};
|
||||
|
||||
// ── Render helpers ────────────────────────────────────────────────────────
|
||||
|
||||
function renderMappings() {
|
||||
if (!preview) return null;
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs border border-zinc-800 rounded-lg">
|
||||
<thead className="bg-zinc-900">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left text-zinc-400 font-medium">
|
||||
CSV Column
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left text-zinc-400 font-medium">
|
||||
Maps To
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left text-zinc-400 font-medium">
|
||||
Sample Values
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{preview.mappings.map((m) => (
|
||||
<tr key={m.csvColumn} className="border-t border-slate-100">
|
||||
<td className="px-3 py-2 text-slate-800 font-mono text-xs">
|
||||
{m.csvColumn}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<select
|
||||
value={
|
||||
overridingMappings[m.csvColumn] ?? m.field ?? "ignore"
|
||||
}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setOverridingMappings((prev) => ({
|
||||
...prev,
|
||||
[m.csvColumn]:
|
||||
val === "ignore"
|
||||
? null
|
||||
: (val as ImportField),
|
||||
}));
|
||||
}}
|
||||
className="text-xs border border-zinc-600 rounded px-2 py-1"
|
||||
>
|
||||
<option value="ignore">— ignore —</option>
|
||||
{Object.entries(FIELD_LABELS).map(([k, label]) => (
|
||||
<option key={k} value={k}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-zinc-500 text-xs">
|
||||
{m.sampleValues.length > 0
|
||||
? m.sampleValues.join(", ")
|
||||
: "—"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{preview.ignoredColumns.length > 0 && (
|
||||
<p className="text-xs text-zinc-500">
|
||||
Extra columns (stored in metadata):
|
||||
<span className="font-mono">
|
||||
{" "}
|
||||
{preview.ignoredColumns.join(", ")}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderStats() {
|
||||
if (!preview) return null;
|
||||
return (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<Stat label="Total rows" value={preview.totalRows} />
|
||||
<Stat
|
||||
label="Importable"
|
||||
value={preview.validRows}
|
||||
highlight
|
||||
/>
|
||||
<Stat label="Skipped" value={preview.skippedRows} warn={preview.skippedRows > 0} />
|
||||
<Stat
|
||||
label="Duplicates"
|
||||
value={preview.duplicateRows}
|
||||
warn={preview.duplicateRows > 0}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderSampleRows() {
|
||||
if (!preview) return null;
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs border border-zinc-800 rounded-lg">
|
||||
<thead className="bg-zinc-900">
|
||||
<tr>
|
||||
<th className="px-2 py-1 text-left text-zinc-400">#</th>
|
||||
<th className="px-2 py-1 text-left text-zinc-400">email</th>
|
||||
<th className="px-2 py-1 text-left text-zinc-400">phone</th>
|
||||
<th className="px-2 py-1 text-left text-zinc-400">first_name</th>
|
||||
<th className="px-2 py-1 text-left text-zinc-400">last_name</th>
|
||||
<th className="px-2 py-1 text-left text-zinc-400">full_name</th>
|
||||
<th className="px-2 py-1 text-left text-zinc-400">email_opt_in</th>
|
||||
<th className="px-2 py-1 text-left text-zinc-400">sms_opt_in</th>
|
||||
<th className="px-2 py-1 text-left text-zinc-400">tags</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{preview.sampleRows.map((row, i) => (
|
||||
<tr key={i} className="border-t border-slate-100">
|
||||
<td className="px-2 py-1 text-slate-400">{i + 1}</td>
|
||||
<td className="px-2 py-1">{row.email ?? ""}</td>
|
||||
<td className="px-2 py-1">{row.phone ?? ""}</td>
|
||||
<td className="px-2 py-1">{row.first_name ?? ""}</td>
|
||||
<td className="px-2 py-1">{row.last_name ?? ""}</td>
|
||||
<td className="px-2 py-1">{row.full_name ?? ""}</td>
|
||||
<td className="px-2 py-1">
|
||||
{row.email_opt_in === undefined
|
||||
? ""
|
||||
: row.email_opt_in
|
||||
? "true"
|
||||
: "false"}
|
||||
</td>
|
||||
<td className="px-2 py-1">
|
||||
{row.sms_opt_in === undefined
|
||||
? ""
|
||||
: row.sms_opt_in
|
||||
? "true"
|
||||
: "false"}
|
||||
</td>
|
||||
<td className="px-2 py-1">{row.tags?.join("; ") ?? ""}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main render ────────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<div className="bg-zinc-900 rounded-xl border border-zinc-800 p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-slate-800">Import Contacts</h3>
|
||||
{step !== "idle" && (
|
||||
<button
|
||||
onClick={handleReset}
|
||||
className="text-xs text-zinc-500 hover:text-zinc-300"
|
||||
>
|
||||
← Start over
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{globalError && (
|
||||
<div className="rounded-lg bg-red-900/30 border border-red-200 px-4 py-3 text-sm text-red-400">
|
||||
{globalError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── STEP: idle ─────────────────────────────────────────── */}
|
||||
{step === "idle" && (
|
||||
<>
|
||||
<div
|
||||
className="border-2 border-dashed border-zinc-600 rounded-xl p-8 text-center"
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept=".csv"
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
id="csv-upload"
|
||||
/>
|
||||
<label htmlFor="csv-upload" className="cursor-pointer">
|
||||
<div className="text-zinc-400 text-sm">
|
||||
<span className="font-medium text-blue-400 hover:text-blue-700">
|
||||
Click to upload
|
||||
</span>
|
||||
{" "}or drag and drop a CSV file
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
Works with Mailchimp, Square, Shopify, WooCommerce, QuickBooks, and
|
||||
generic spreadsheets
|
||||
</p>
|
||||
</label>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── STEP: preview ──────────────────────────────────────── */}
|
||||
{step === "preview" && preview && (
|
||||
<>
|
||||
{preview.warnings.map((w, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="rounded-lg bg-amber-900/30 border border-amber-200 px-4 py-3 text-sm text-amber-700"
|
||||
>
|
||||
{w}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="rounded-lg bg-blue-900/30 border border-blue-200 px-4 py-3 text-sm text-blue-700">
|
||||
<p className="font-medium mb-1">{file?.name}</p>
|
||||
<p>
|
||||
Column mappings detected — review below, adjust if needed, then
|
||||
confirm import.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{renderMappings()}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs text-zinc-500 mb-2 font-medium">
|
||||
Import Summary
|
||||
</p>
|
||||
{renderStats()}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs text-zinc-500 mb-2 font-medium">
|
||||
Sample rows (first {preview.sampleRows.length})
|
||||
</p>
|
||||
{renderSampleRows()}
|
||||
</div>
|
||||
|
||||
{preview.skippedReasons.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs text-zinc-500 mb-1 font-medium">
|
||||
Skipped rows ({preview.skippedReasons.length})
|
||||
</p>
|
||||
<div className="max-h-32 overflow-y-auto text-xs text-zinc-500 space-y-1">
|
||||
{preview.skippedReasons.slice(0, 10).map((s) => (
|
||||
<p key={s.rowIndex}>
|
||||
Row {s.rowIndex + 1}: {s.reason}
|
||||
</p>
|
||||
))}
|
||||
{preview.skippedReasons.length > 10 && (
|
||||
<p className="text-slate-400">
|
||||
...and{" "}
|
||||
{preview.skippedReasons.length - 10} more
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{preview.totalRows > LARGE_FILE_ROWS && (
|
||||
<div className="rounded-lg bg-amber-900/30 border border-amber-200 px-4 py-3 text-sm text-amber-700">
|
||||
Large file ({preview.totalRows.toLocaleString()} rows) — confirm to
|
||||
proceed.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => handleReset()}
|
||||
className="rounded-lg border border-zinc-600 px-4 py-2 text-sm text-zinc-400 hover:bg-zinc-900"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleConfirm()}
|
||||
disabled={preview.validRows === 0}
|
||||
className="rounded-lg bg-blue-600 px-5 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
Import {preview.validRows} contacts
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── STEP: importing ────────────────────────────────────── */}
|
||||
{step === "importing" && (
|
||||
<div className="rounded-lg bg-blue-900/30 border border-blue-200 px-4 py-6 text-center text-sm text-blue-700">
|
||||
Importing {importRows.length} contacts...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── STEP: result ────────────────────────────────────────── */}
|
||||
{step === "result" && result && (
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-lg bg-green-900/30 border border-green-200 px-4 py-3 text-sm space-y-1">
|
||||
<p className="font-semibold text-green-400">Import complete</p>
|
||||
<p className="text-green-600">Created: {result.created}</p>
|
||||
<p className="text-green-600">Updated: {result.updated}</p>
|
||||
<p className="text-green-600">Skipped: {result.skipped}</p>
|
||||
</div>
|
||||
|
||||
{result.errors.length > 0 && (
|
||||
<div className="rounded-lg bg-red-900/30 border border-red-200 px-4 py-3 text-sm">
|
||||
<p className="font-medium text-red-400 mb-2">
|
||||
Errors ({result.errors.length})
|
||||
</p>
|
||||
<div className="max-h-40 overflow-y-auto space-y-1">
|
||||
{result.errors.slice(0, 10).map((e, i) => (
|
||||
<p key={i} className="text-red-400 text-xs">
|
||||
{e.error}: {JSON.stringify(e.row).slice(0, 80)}
|
||||
</p>
|
||||
))}
|
||||
{result.errors.length > 10 && (
|
||||
<p className="text-red-400 text-xs">
|
||||
...and {result.errors.length - 10} more
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleReset}
|
||||
className="rounded-lg border border-zinc-600 px-4 py-2 text-sm text-zinc-400 hover:bg-zinc-900"
|
||||
>
|
||||
Import more
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({
|
||||
label,
|
||||
value,
|
||||
highlight,
|
||||
warn,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
highlight?: boolean;
|
||||
warn?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`rounded-lg border px-4 py-3 text-center ${
|
||||
highlight
|
||||
? "bg-blue-900/30 border-blue-200"
|
||||
: warn
|
||||
? "bg-amber-900/30 border-amber-200"
|
||||
: "bg-zinc-900 border-zinc-800"
|
||||
}`}
|
||||
>
|
||||
<p
|
||||
className={`text-xl font-semibold ${
|
||||
highlight
|
||||
? "text-blue-700"
|
||||
: warn
|
||||
? "text-amber-700"
|
||||
: "text-zinc-300"
|
||||
}`}
|
||||
>
|
||||
{value.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-xs text-zinc-500 mt-0.5">{label}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import type { Contact, ContactSource } from "@/actions/communications/contacts";
|
||||
import { getContacts, deleteContact, exportContacts } from "@/actions/communications/contacts";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
|
||||
const SOURCE_COLORS: Record<ContactSource, string> = {
|
||||
order: "bg-blue-900/40 text-blue-700",
|
||||
import: "bg-purple-100 text-purple-700",
|
||||
manual: "bg-green-900/40 text-green-400",
|
||||
admin: "bg-zinc-950 text-zinc-300",
|
||||
};
|
||||
|
||||
export default function ContactListPanel({
|
||||
initialContacts,
|
||||
initialTotal,
|
||||
brandId,
|
||||
}: {
|
||||
initialContacts: Contact[];
|
||||
initialTotal: number;
|
||||
brandId: string;
|
||||
}) {
|
||||
const [contacts, setContacts] = useState(initialContacts);
|
||||
const [total, setTotal] = useState(initialTotal);
|
||||
const [search, setSearch] = useState("");
|
||||
const [sourceFilter, setSourceFilter] = useState<ContactSource | "">("");
|
||||
const [page, setPage] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [deleting, setDeleting] = useState<string | null>(null);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const limit = 50;
|
||||
|
||||
const loadPage = useCallback(async (searchVal: string, sourceVal: string, pageNum: number) => {
|
||||
setLoading(true);
|
||||
const result = await getContacts({
|
||||
brandId,
|
||||
search: searchVal || undefined,
|
||||
source: sourceVal || undefined,
|
||||
limit,
|
||||
offset: pageNum * limit,
|
||||
});
|
||||
setLoading(false);
|
||||
if (result.success) {
|
||||
setContacts(result.contacts);
|
||||
setTotal(result.total);
|
||||
}
|
||||
}, [brandId]);
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setPage(0);
|
||||
loadPage(search, sourceFilter, 0);
|
||||
};
|
||||
|
||||
const handleSourceFilter = (val: ContactSource | "") => {
|
||||
setSourceFilter(val);
|
||||
setPage(0);
|
||||
loadPage(search, val, 0);
|
||||
};
|
||||
|
||||
const handlePage = (dir: number) => {
|
||||
const next = page + dir;
|
||||
setPage(next);
|
||||
loadPage(search, sourceFilter, next);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm("Delete this contact?")) return;
|
||||
setDeleting(id);
|
||||
const result = await deleteContact(id);
|
||||
setDeleting(null);
|
||||
if (result.success) {
|
||||
setContacts((prev) => prev.filter((c) => c.id !== id));
|
||||
setTotal((t) => t - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = async () => {
|
||||
setExporting(true);
|
||||
const result = await exportContacts({
|
||||
brandId,
|
||||
brandSlug: "contacts",
|
||||
search: search || undefined,
|
||||
source: sourceFilter || undefined,
|
||||
});
|
||||
setExporting(false);
|
||||
if (!result.success) {
|
||||
alert("Export failed: " + result.error);
|
||||
return;
|
||||
}
|
||||
const blob = new Blob([result.csv], { type: "text/csv;charset=utf-8;" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = result.filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-zinc-100">Contacts</h2>
|
||||
<p className="text-sm text-zinc-500">{total} contact{total !== 1 ? "s" : ""}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleExport}
|
||||
disabled={exporting || total === 0}
|
||||
className="rounded-lg border border-zinc-600 bg-zinc-900 px-4 py-2 text-sm font-medium text-zinc-300 hover:bg-zinc-800 disabled:opacity-50"
|
||||
>
|
||||
{exporting ? "Exporting..." : "Export Contacts"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search + filters */}
|
||||
<form onSubmit={handleSearch} className="flex gap-3 mb-4">
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search email, name, phone..."
|
||||
className="flex-1 text-sm border border-zinc-600 rounded-lg px-3 py-2"
|
||||
/>
|
||||
<select
|
||||
value={sourceFilter}
|
||||
onChange={(e) => handleSourceFilter(e.target.value as ContactSource | "")}
|
||||
className="text-sm border border-zinc-600 rounded-lg px-3 py-2"
|
||||
>
|
||||
<option value="">All Sources</option>
|
||||
<option value="order">Order</option>
|
||||
<option value="import">Import</option>
|
||||
<option value="manual">Manual</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Table */}
|
||||
{loading ? (
|
||||
<div className="text-center py-12 text-slate-400">Loading...</div>
|
||||
) : contacts.length === 0 ? (
|
||||
<div className="text-center py-12 text-slate-400">No contacts found</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="hidden sm:block bg-zinc-900 rounded-xl border border-zinc-800 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-50 border-b border-zinc-800">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3 font-medium text-zinc-400">Name</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-zinc-400">Email</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-zinc-400">Phone</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-zinc-400">Source</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-zinc-400">Email Opt-in</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-zinc-400">Unsubscribed</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-zinc-400"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{contacts.map((c) => (
|
||||
<tr key={c.id} className="hover:bg-zinc-800">
|
||||
<td className="px-4 py-3 font-medium text-zinc-100">
|
||||
{c.full_name || `${c.first_name || ""} ${c.last_name || ""}`.trim() || "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-zinc-400">{c.email || "—"}</td>
|
||||
<td className="px-4 py-3 text-zinc-400">{c.phone || "—"}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${SOURCE_COLORS[c.source]}`}>
|
||||
{c.source}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{c.email_opt_in ? (
|
||||
<span className="text-green-600 text-xs font-medium">Opted in</span>
|
||||
) : (
|
||||
<span className="text-red-500 text-xs font-medium">Opted out</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-zinc-500 text-xs">
|
||||
{c.unsubscribed_at
|
||||
? formatDate(new Date(c.unsubscribed_at))
|
||||
: "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<button
|
||||
onClick={() => handleDelete(c.id)}
|
||||
disabled={deleting === c.id}
|
||||
className="text-red-500 hover:text-red-400 text-xs disabled:opacity-50"
|
||||
>
|
||||
{deleting === c.id ? "..." : "Delete"}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Mobile cards */}
|
||||
<div className="sm:hidden space-y-3">
|
||||
{contacts.map((c) => (
|
||||
<div key={c.id} className="bg-zinc-900 rounded-xl border border-zinc-800 p-4 space-y-2">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="font-medium text-zinc-100 text-sm">
|
||||
{c.full_name || `${c.first_name || ""} ${c.last_name || ""}`.trim() || "—"}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDelete(c.id)}
|
||||
disabled={deleting === c.id}
|
||||
className="text-red-500 hover:text-red-400 text-xs disabled:opacity-50"
|
||||
>
|
||||
{deleting === c.id ? "..." : "Delete"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-xs text-zinc-400">{c.email || "—"}</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${SOURCE_COLORS[c.source]}`}>
|
||||
{c.source}
|
||||
</span>
|
||||
{c.phone && <span className="text-xs text-zinc-500">{c.phone}</span>}
|
||||
{c.email_opt_in ? (
|
||||
<span className="text-green-600 text-xs font-medium">Opted in</span>
|
||||
) : (
|
||||
<span className="text-red-500 text-xs font-medium">Opted out</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="flex items-center justify-between mt-4">
|
||||
<span className="text-sm text-zinc-500">
|
||||
Page {page + 1} — {contacts.length} of {total}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handlePage(-1)}
|
||||
disabled={page === 0}
|
||||
className="rounded-lg border border-zinc-600 px-3 py-1.5 text-sm disabled:opacity-50 hover:bg-zinc-800"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handlePage(1)}
|
||||
disabled={(page + 1) * limit >= total}
|
||||
className="rounded-lg border border-zinc-600 px-3 py-1.5 text-sm disabled:opacity-50 hover:bg-zinc-800"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import Link from "next/link";
|
||||
import { markPickupComplete } from "@/actions/pickup";
|
||||
|
||||
type OrderItem = {
|
||||
id: string;
|
||||
product_id: string;
|
||||
quantity: number;
|
||||
price: number;
|
||||
fulfillment?: string;
|
||||
products: { name: string } | null;
|
||||
};
|
||||
|
||||
type Order = {
|
||||
id: string;
|
||||
customer_name: string;
|
||||
customer_email: string | null;
|
||||
customer_phone: string | null;
|
||||
stop_id: string | null;
|
||||
status: string;
|
||||
subtotal: number;
|
||||
pickup_complete: boolean;
|
||||
pickup_completed_at: string | null;
|
||||
pickup_completed_by: string | null;
|
||||
created_at: string;
|
||||
stops: { id?: string; city: string; state: string; date: string; brand_id?: string } | null;
|
||||
order_items?: OrderItem[];
|
||||
};
|
||||
|
||||
type Stop = {
|
||||
id: string;
|
||||
city: string;
|
||||
state: string;
|
||||
date: string;
|
||||
brand_id?: string;
|
||||
};
|
||||
|
||||
type DriverPickupPanelProps = {
|
||||
initialPendingOrders: Order[];
|
||||
initialPickedUpOrders: Order[];
|
||||
initialStops: Stop[];
|
||||
brandId: string | null;
|
||||
canManagePickup: boolean;
|
||||
};
|
||||
|
||||
function formatPickupTime(iso: string | null) {
|
||||
if (!iso) return "";
|
||||
const d = new Date(iso);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - d.getTime();
|
||||
const diffMin = Math.floor(diffMs / 60000);
|
||||
|
||||
if (diffMin < 1) return "Just now";
|
||||
if (diffMin < 60) return `${diffMin}m ago`;
|
||||
if (diffMin < 1440) return `${Math.floor(diffMin / 60)}h ago`;
|
||||
|
||||
// Same year — show mon/day
|
||||
if (d.getFullYear() === now.getFullYear()) {
|
||||
return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||
}
|
||||
return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
|
||||
}
|
||||
|
||||
function shortId(id: string) {
|
||||
return id.slice(0, 8).toUpperCase();
|
||||
}
|
||||
|
||||
export default function DriverPickupPanel({
|
||||
initialPendingOrders,
|
||||
initialPickedUpOrders,
|
||||
initialStops,
|
||||
brandId,
|
||||
canManagePickup,
|
||||
}: DriverPickupPanelProps) {
|
||||
const [pendingOrders, setPendingOrders] = useState<Order[]>(initialPendingOrders);
|
||||
const [pickedUpOrders, setPickedUpOrders] = useState<Order[]>(initialPickedUpOrders);
|
||||
const [stops] = useState<Stop[]>(initialStops);
|
||||
const [search, setSearch] = useState("");
|
||||
const [stopFilter, setStopFilter] = useState("");
|
||||
const [pickingUp, setPickingUp] = useState<string | null>(null);
|
||||
const [showPickedUp, setShowPickedUp] = useState(true);
|
||||
const [pickupToast, setPickupToast] = useState<string | null>(null);
|
||||
|
||||
const activeStopFilter = stopFilter;
|
||||
|
||||
const filteredPending = pendingOrders.filter((o) => {
|
||||
const matchesSearch =
|
||||
!search ||
|
||||
o.customer_name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
(o.customer_phone ?? "").includes(search) ||
|
||||
o.id.includes(search.toUpperCase().slice(0, 8));
|
||||
|
||||
const matchesStop = !activeStopFilter || o.stop_id === activeStopFilter;
|
||||
|
||||
return matchesSearch && matchesStop;
|
||||
});
|
||||
|
||||
const filteredPickedUp = pickedUpOrders.filter((o) => {
|
||||
const matchesSearch =
|
||||
!search ||
|
||||
o.customer_name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
(o.customer_phone ?? "").includes(search) ||
|
||||
o.id.includes(search.toUpperCase().slice(0, 8));
|
||||
|
||||
const matchesStop = !activeStopFilter || o.stop_id === activeStopFilter;
|
||||
|
||||
return matchesSearch && matchesStop;
|
||||
});
|
||||
|
||||
const hasAnyPickedUp = pickedUpOrders.length > 0;
|
||||
|
||||
async function handleMarkPickup(orderId: string) {
|
||||
if (!canManagePickup) return;
|
||||
setPickingUp(orderId);
|
||||
|
||||
const result = await markPickupComplete(orderId, brandId);
|
||||
|
||||
if (result.success) {
|
||||
const order = pendingOrders.find((o) => o.id === orderId);
|
||||
if (order) {
|
||||
setPendingOrders((prev) => prev.filter((o) => o.id !== orderId));
|
||||
setPickedUpOrders((prev) => [
|
||||
{
|
||||
...order,
|
||||
pickup_complete: true,
|
||||
pickup_completed_at: result.pickup_completed_at,
|
||||
pickup_completed_by: result.pickup_completed_by,
|
||||
},
|
||||
...prev,
|
||||
]);
|
||||
setPickupToast(orderId);
|
||||
setTimeout(() => setPickupToast(null), 3000);
|
||||
}
|
||||
}
|
||||
|
||||
setPickingUp(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-950">
|
||||
{/* Header */}
|
||||
<div className="bg-slate-900 px-4 py-5">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Driver Pickup</h1>
|
||||
<p className="mt-1 text-sm text-slate-400">
|
||||
{filteredPending.length} pending
|
||||
{hasAnyPickedUp && ` · ${pickedUpOrders.length} picked up`}
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/admin/orders"
|
||||
className="rounded-lg bg-slate-700 px-4 py-2 text-sm font-medium text-white hover:bg-slate-600"
|
||||
>
|
||||
All Orders
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto max-w-6xl px-4 py-4 space-y-4">
|
||||
{/* Stop Filter */}
|
||||
<select
|
||||
value={stopFilter}
|
||||
onChange={(e) => setStopFilter(e.target.value)}
|
||||
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-4 text-base font-medium outline-none focus:border-slate-900"
|
||||
>
|
||||
<option value="">All Stops</option>
|
||||
{stops.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.city}, {s.state} · {s.date}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Search */}
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search name, phone, or order #..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-4 text-base outline-none focus:border-slate-900"
|
||||
/>
|
||||
|
||||
{/* Pending Orders */}
|
||||
<div className="space-y-3">
|
||||
{filteredPending.length === 0 ? (
|
||||
<div className="rounded-xl bg-zinc-900 py-12 text-center text-slate-400">
|
||||
No pending orders
|
||||
</div>
|
||||
) : (
|
||||
filteredPending.map((order) => (
|
||||
<OrderCard
|
||||
key={order.id}
|
||||
order={order}
|
||||
canManagePickup={canManagePickup}
|
||||
onMarkPickup={handleMarkPickup}
|
||||
pickingUp={pickingUp}
|
||||
shortId={shortId(order.id)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Picked Up Toggle */}
|
||||
{hasAnyPickedUp && (
|
||||
<button
|
||||
onClick={() => setShowPickedUp((v) => !v)}
|
||||
className="w-full rounded-xl border border-green-200 bg-green-900/30 px-4 py-3 text-sm font-medium text-green-400 hover:bg-green-900/40"
|
||||
>
|
||||
{showPickedUp ? "▲ Hide" : "▼"} {pickedUpOrders.length} picked up
|
||||
</button>
|
||||
)}
|
||||
|
||||
{showPickedUp && (
|
||||
<div className="space-y-3">
|
||||
{filteredPickedUp.length === 0 && activeStopFilter ? (
|
||||
<div className="rounded-xl border border-green-200 bg-green-900/30 py-6 text-center text-sm text-green-600">
|
||||
No picked-up orders for this stop. {pickedUpOrders.length} picked up at other stops.
|
||||
</div>
|
||||
) : (
|
||||
filteredPickedUp.map((order) => (
|
||||
<PickedUpCard
|
||||
key={order.id}
|
||||
order={order}
|
||||
shortId={shortId(order.id)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Success toast */}
|
||||
{pickupToast && (
|
||||
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 rounded-xl bg-green-600 px-6 py-3 text-sm font-semibold text-white shadow-lg animate-in fade-in slide-in-from-bottom-2">
|
||||
✓ Order picked up
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type OrderCardProps = {
|
||||
order: Order;
|
||||
canManagePickup: boolean;
|
||||
onMarkPickup: (orderId: string) => void;
|
||||
pickingUp: string | null;
|
||||
shortId: string;
|
||||
};
|
||||
|
||||
function OrderCard({ order, canManagePickup, onMarkPickup, pickingUp, shortId }: OrderCardProps) {
|
||||
const allItems = order.order_items ?? [];
|
||||
const pickupItems = allItems.filter((i) => i.fulfillment !== "shipping");
|
||||
const shippingItems = allItems.filter((i) => i.fulfillment === "shipping");
|
||||
const isMixed = pickupItems.length > 0 && shippingItems.length > 0;
|
||||
const pickupTotal = pickupItems.reduce((sum, i) => sum + Number(i.price) * i.quantity, 0);
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl bg-zinc-900 p-5 shadow-black/20 ring-1 ring-zinc-700">
|
||||
{/* Top row: name + order # */}
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xl font-bold text-zinc-100">{order.customer_name}</p>
|
||||
{order.customer_phone && (
|
||||
<p className="mt-1 text-base text-zinc-400">{order.customer_phone}</p>
|
||||
)}
|
||||
<p className="mt-1 font-mono text-xs text-slate-400 uppercase">{shortId}</p>
|
||||
</div>
|
||||
<div className="text-right shrink-0 flex flex-col items-end gap-1">
|
||||
<span className="rounded-full bg-yellow-100 px-3 py-1 text-xs font-bold text-yellow-800">
|
||||
Pending
|
||||
</span>
|
||||
{isMixed && (
|
||||
<span className="rounded-full bg-purple-100 px-2 py-0.5 text-xs font-semibold text-purple-700">
|
||||
Mixed order
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Products list — pickup items only */}
|
||||
{pickupItems.length > 0 && (
|
||||
<div className="mt-4 rounded-lg bg-slate-50 p-3">
|
||||
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-zinc-500">
|
||||
Pickup items ({pickupItems.reduce((s, i) => s + i.quantity, 0)} total)
|
||||
</p>
|
||||
<ul className="space-y-1">
|
||||
{pickupItems.map((item) => (
|
||||
<li key={item.id} className="flex items-center justify-between text-sm">
|
||||
<span className="text-zinc-300">
|
||||
{item.quantity > 1 && (
|
||||
<span className="font-semibold text-zinc-100">{item.quantity}× </span>
|
||||
)}
|
||||
{item.products?.name ?? "Unknown Product"}
|
||||
</span>
|
||||
<span className="text-zinc-500">
|
||||
${(Number(item.price) * item.quantity).toFixed(2)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="mt-2 border-t border-zinc-800 pt-2 flex justify-between">
|
||||
<span className="text-sm font-medium text-zinc-400">Pickup subtotal</span>
|
||||
<span className="text-sm font-bold text-zinc-100">${pickupTotal.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Shipping items note */}
|
||||
{isMixed && (
|
||||
<div className="mt-3 rounded-lg bg-purple-50 border border-purple-100 px-3 py-2 text-sm text-purple-700">
|
||||
⚠ {shippingItems.length} shipping item{shippingItems.length > 1 ? "s" : ""} — will also be marked picked up
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Middle: stop + items count */}
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<div>
|
||||
{order.stops ? (
|
||||
<p className="font-medium text-zinc-300">
|
||||
{order.stops.city}, {order.stops.state}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-slate-400">No stop</p>
|
||||
)}
|
||||
<p className="text-sm text-slate-400">
|
||||
{pickupItems.length} pickup {pickupItems.length === 1 ? "item" : "items"}
|
||||
{isMixed && ` · ${shippingItems.length} shipping`}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-zinc-100">
|
||||
${pickupTotal.toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Pickup Button */}
|
||||
{canManagePickup ? (
|
||||
<div className="mt-4 flex gap-2">
|
||||
<a
|
||||
href={`/admin/orders/${order.id}`}
|
||||
className="flex-1 rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-4 text-center text-base font-semibold text-zinc-300 active:bg-slate-50"
|
||||
style={{ minHeight: "56px", display: "flex", alignItems: "center", justifyContent: "center" }}
|
||||
>
|
||||
View Details
|
||||
</a>
|
||||
<button
|
||||
onClick={() => onMarkPickup(order.id)}
|
||||
disabled={pickingUp === order.id}
|
||||
className="flex-1 rounded-xl bg-green-600 px-4 py-5 text-xl font-bold text-white active:bg-green-700 disabled:opacity-50"
|
||||
style={{ minHeight: "56px" }}
|
||||
>
|
||||
{pickingUp === order.id ? "..." : "✓ Pick Up"}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 rounded-xl bg-zinc-950 px-4 py-3 text-center text-sm text-zinc-500">
|
||||
No pickup permission
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type PickedUpCardProps = {
|
||||
order: Order;
|
||||
shortId: string;
|
||||
};
|
||||
|
||||
function PickedUpCard({ order, shortId }: PickedUpCardProps) {
|
||||
const allItems = order.order_items ?? [];
|
||||
const pickupItems = allItems.filter((i) => i.fulfillment === "pickup");
|
||||
const pickupTotal = pickupItems.reduce((s, i) => s + Number(i.price) * i.quantity, 0);
|
||||
const isMixed = allItems.some((i) => i.fulfillment === "shipping");
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-green-200 bg-green-900/30 p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="font-bold text-green-900">{order.customer_name}</p>
|
||||
{order.customer_phone && (
|
||||
<p className="text-sm text-green-400">{order.customer_phone}</p>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-green-600">
|
||||
{shortId} · {order.stops?.city}, {order.stops?.state}
|
||||
</p>
|
||||
{pickupItems.length > 0 && (
|
||||
<ul className="mt-2 space-y-0.5">
|
||||
{pickupItems.map((item) => (
|
||||
<li key={item.id} className="text-xs text-green-400">
|
||||
{item.quantity > 1 && <span className="font-semibold">{item.quantity}× </span>}
|
||||
{item.products?.name ?? "Unknown"}
|
||||
<span className="ml-1 text-green-500">${(Number(item.price) * item.quantity).toFixed(2)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<p className="mt-2 text-xs font-semibold text-green-400">
|
||||
{pickupItems.reduce((s, i) => s + i.quantity, 0)} pickup items · ${pickupTotal.toFixed(2)}
|
||||
{isMixed && <span className="text-purple-600 ml-1">· mixed</span>}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right shrink-0">
|
||||
<span className="rounded-full bg-green-200 px-3 py-1 text-xs font-bold text-green-800">
|
||||
Picked Up
|
||||
</span>
|
||||
{order.pickup_completed_at && (
|
||||
<p className="mt-1 text-xs text-green-600">
|
||||
{formatPickupTime(order.pickup_completed_at)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { type CampaignAnalytics } from "@/actions/harvest-reach/campaigns";
|
||||
|
||||
type Props = {
|
||||
analytics: CampaignAnalytics[];
|
||||
};
|
||||
|
||||
type Period = "30" | "90" | "all";
|
||||
|
||||
function RateBar({ value }: { value: number }) {
|
||||
return (
|
||||
<div className="w-full bg-zinc-950 rounded-full h-1.5">
|
||||
<div
|
||||
className="bg-stone-900 h-1.5 rounded-full"
|
||||
style={{ width: `${Math.min(100, value)}%` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({ label, value, sub }: { label: string; value: string | number; sub?: string }) {
|
||||
return (
|
||||
<div className="bg-zinc-900 rounded-xl border border-zinc-800 p-5 flex flex-col gap-1.5">
|
||||
<p className="text-xs font-medium text-zinc-500 uppercase tracking-wide">{label}</p>
|
||||
<p className="text-2xl font-bold text-zinc-100">{value}</p>
|
||||
{sub && <p className="text-xs text-slate-400">{sub}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AnalyticsDashboard({ analytics }: Props) {
|
||||
const [period, setPeriod] = useState<Period>("30");
|
||||
|
||||
const totalSent = analytics.reduce((s, a) => s + a.total_sent, 0);
|
||||
const totalDelivered = analytics.reduce((s, a) => s + a.total_delivered, 0);
|
||||
const totalOpened = analytics.reduce((s, a) => s + a.total_opened, 0);
|
||||
const totalClicked = analytics.reduce((s, a) => s + a.total_clicked, 0);
|
||||
|
||||
const avgOpenRate = totalDelivered > 0 ? (totalOpened / totalDelivered) * 100 : 0;
|
||||
const avgClickRate = totalDelivered > 0 ? (totalClicked / totalDelivered) * 100 : 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
{/* Summary stat cards */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<StatCard label="Total Sent" value={totalSent.toLocaleString()} sub={`${analytics.length} campaign${analytics.length !== 1 ? "s" : ""}`} />
|
||||
<StatCard
|
||||
label="Delivered"
|
||||
value={totalSent > 0 ? `${Math.round((totalDelivered / totalSent) * 100)}%` : "—"}
|
||||
sub={totalDelivered > 0 ? `${totalDelivered.toLocaleString()} emails` : undefined}
|
||||
/>
|
||||
<StatCard label="Avg. Open Rate" value={totalSent > 0 ? `${avgOpenRate.toFixed(1)}%` : "—"} sub="of delivered" />
|
||||
<StatCard label="Avg. Click Rate" value={totalSent > 0 ? `${avgClickRate.toFixed(1)}%` : "—"} sub="of delivered" />
|
||||
</div>
|
||||
|
||||
{/* Campaign performance table */}
|
||||
<div className="bg-zinc-900 rounded-xl border border-zinc-800 overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-zinc-800 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-slate-800">Campaign Performance</h3>
|
||||
<div className="flex rounded-lg border border-zinc-800 bg-slate-50 p-0.5">
|
||||
{(["30", "90", "all"] as const).map((val) => (
|
||||
<button
|
||||
key={val}
|
||||
onClick={() => setPeriod(val as Period)}
|
||||
className={`px-3 py-1 text-xs font-medium rounded-md transition-colors ${
|
||||
period === val ? "bg-stone-900 text-white" : "text-zinc-400 hover:bg-zinc-900"
|
||||
}`}
|
||||
>
|
||||
{val === "30" ? "30 days" : val === "90" ? "90 days" : "All time"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{analytics.length === 0 ? (
|
||||
<div className="px-6 py-12 text-center">
|
||||
<p className="text-sm text-slate-400">No campaign analytics yet.</p>
|
||||
<p className="text-xs text-slate-400 mt-1">Send a campaign to start tracking engagement.</p>
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-50 border-b border-zinc-800">
|
||||
<tr>
|
||||
{["Campaign", "Sent", "Delivered", "Opened", "Clicked", "Bounced", "Engagement"].map((h) => (
|
||||
<th key={h} className={`text-left px-6 py-3 font-medium text-zinc-400 ${h !== "Campaign" ? "text-right" : ""}`}>{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{analytics.map((a) => (
|
||||
<tr key={a.campaign_id} className="hover:bg-zinc-800 transition-colors">
|
||||
<td className="px-6 py-3.5">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium text-slate-800">{a.campaign_name}</span>
|
||||
<span className="text-xs text-slate-400">
|
||||
{a.sent_at ? new Date(a.sent_at).toLocaleDateString() : "—"}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-3.5 text-right text-zinc-300">{a.total_sent.toLocaleString()}</td>
|
||||
<td className="px-6 py-3.5 text-right">
|
||||
<span className="text-zinc-300">{a.total_delivered.toLocaleString()}</span>
|
||||
<span className="text-xs text-slate-400 ml-1">({a.delivered_rate}%)</span>
|
||||
</td>
|
||||
<td className="px-6 py-3.5 text-right">
|
||||
<span className="text-zinc-300">{a.total_opened.toLocaleString()}</span>
|
||||
<span className="text-xs text-slate-400 ml-1">({a.open_rate}%)</span>
|
||||
</td>
|
||||
<td className="px-6 py-3.5 text-right">
|
||||
<span className="text-zinc-300">{a.total_clicked.toLocaleString()}</span>
|
||||
<span className="text-xs text-slate-400 ml-1">({a.click_rate}%)</span>
|
||||
</td>
|
||||
<td className="px-6 py-3.5 text-right">
|
||||
<span className={a.total_bounced > 0 ? "text-red-400" : "text-slate-400"}>
|
||||
{a.total_bounced.toLocaleString()}
|
||||
</span>
|
||||
<span className="text-xs text-slate-400 ml-1">({a.bounce_rate}%)</span>
|
||||
</td>
|
||||
<td className="px-6 py-3.5 w-36">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-zinc-500">Open</span>
|
||||
<span className="font-medium text-zinc-300">{a.open_rate}%</span>
|
||||
</div>
|
||||
<RateBar value={Number(a.open_rate)} />
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-zinc-500">Click</span>
|
||||
<span className="font-medium text-zinc-300">{a.click_rate}%</span>
|
||||
</div>
|
||||
<RateBar value={Number(a.click_rate)} />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,505 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { type Campaign, type CampaignType } from "@/actions/harvest-reach/campaigns";
|
||||
import { type Template } from "@/actions/communications/templates";
|
||||
import type { Segment, SegmentRuleV2 } from "@/actions/harvest-reach/segments";
|
||||
|
||||
type Props = {
|
||||
brandId: string;
|
||||
campaigns: Campaign[];
|
||||
templates: Template[];
|
||||
segments: Segment[];
|
||||
editCampaignId?: string;
|
||||
};
|
||||
|
||||
type Step = 1 | 2 | 3 | 4;
|
||||
|
||||
const STEPS = ["Template", "Audience", "Preview", "Schedule"] as const;
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
draft: "bg-zinc-950 text-zinc-400",
|
||||
scheduled: "bg-blue-900/40 text-blue-700",
|
||||
sending: "bg-amber-100 text-amber-700",
|
||||
sent: "bg-green-900/40 text-green-400",
|
||||
canceled: "bg-red-900/40 text-red-400",
|
||||
};
|
||||
|
||||
const CAMPAIGN_TYPES: { value: CampaignType; label: string; desc: string }[] = [
|
||||
{ value: "marketing", label: "Marketing", desc: "Promotions, newsletters" },
|
||||
{ value: "operational", label: "Operational", desc: "Stop updates, reminders" },
|
||||
{ value: "transactional", label: "Transactional", desc: "Receipts, confirmations" },
|
||||
];
|
||||
|
||||
function statusBadge(status: string) {
|
||||
return (
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${STATUS_COLORS[status] ?? "bg-zinc-950 text-zinc-400"}`}>
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CampaignComposerPage({ brandId, campaigns, templates, segments, editCampaignId }: Props) {
|
||||
const editing = campaigns.find((c) => c.id === editCampaignId);
|
||||
|
||||
const [step, setStep] = useState<Step>(editing ? 4 : 1);
|
||||
const [name, setName] = useState(editing?.name ?? "");
|
||||
const [campaignType, setCampaignType] = useState<CampaignType>(editing?.campaign_type ?? "marketing");
|
||||
const [selectedTemplateId, setSelectedTemplateId] = useState<string>(editing?.template_id ?? "");
|
||||
const [subject, setSubject] = useState(editing?.subject ?? "");
|
||||
const [bodyText, setBodyText] = useState(editing?.body_text ?? "");
|
||||
const [bodyHtml, setBodyHtml] = useState(editing?.body_html ?? "");
|
||||
const [selectedSegmentId, setSelectedSegmentId] = useState<string>("");
|
||||
const [sendNow, setSendNow] = useState(!editing?.scheduled_at);
|
||||
const [scheduledAt, setScheduledAt] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
const selectedTemplate = templates.find((t) => t.id === selectedTemplateId);
|
||||
const selectedSegment = segments.find((s) => s.id === selectedSegmentId);
|
||||
|
||||
function handleTemplateSelect(template: Template) {
|
||||
setSelectedTemplateId(template.id);
|
||||
setSubject(template.subject ?? "");
|
||||
setBodyText(template.body_text ?? "");
|
||||
setBodyHtml(template.body_html ?? "");
|
||||
setStep(2);
|
||||
}
|
||||
|
||||
async function handleCreateOrSchedule() {
|
||||
setError("");
|
||||
setSaving(true);
|
||||
const rules = selectedSegment?.rules ?? { combinator: "AND", filters: [] };
|
||||
const { upsertCampaign } = await import("@/actions/communications/campaigns");
|
||||
const { sendCampaign } = await import("@/actions/communications/send");
|
||||
|
||||
const result = await upsertCampaign({
|
||||
id: editing?.id,
|
||||
brand_id: brandId,
|
||||
name: name || "Untitled Campaign",
|
||||
subject,
|
||||
body_text: bodyText,
|
||||
body_html: bodyHtml,
|
||||
template_id: selectedTemplateId || undefined,
|
||||
campaign_type: campaignType,
|
||||
status: sendNow ? "draft" : "scheduled",
|
||||
audience_rules: rules as any,
|
||||
scheduled_at: sendNow ? undefined : scheduledAt || undefined,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
setError(result.error ?? "Failed to save campaign");
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (sendNow) {
|
||||
const sendResult = await sendCampaign(result.campaign.id, brandId);
|
||||
if (!sendResult.success) {
|
||||
setError(sendResult.error ?? "Failed to send campaign");
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setSaved(true);
|
||||
setSaving(false);
|
||||
setTimeout(() => setSaved(false), 3000);
|
||||
}
|
||||
|
||||
if (saved) {
|
||||
return (
|
||||
<div className="bg-zinc-900 rounded-xl border border-zinc-800 p-10 text-center">
|
||||
<div className="w-12 h-12 rounded-full bg-green-900/40 flex items-center justify-center mx-auto mb-4">
|
||||
<svg className="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold text-zinc-100 mb-2">
|
||||
{sendNow ? "Campaign Sent!" : "Campaign Scheduled!"}
|
||||
</h2>
|
||||
<p className="text-sm text-zinc-500 mb-8">
|
||||
{sendNow
|
||||
? "Your campaign has been sent to all matching customers."
|
||||
: `It will be sent on ${new Date(scheduledAt).toLocaleString()}.`}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => window.location.href = "/admin/communications"}
|
||||
className="px-6 py-2.5 rounded-lg bg-stone-900 text-white text-sm font-medium hover:bg-stone-800"
|
||||
>
|
||||
Back to Campaigns
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Step indicator */}
|
||||
<div className="flex items-center">
|
||||
{STEPS.map((label, i) => {
|
||||
const n = (i + 1) as Step;
|
||||
const done = step > n;
|
||||
return (
|
||||
<div key={label} className="flex items-center">
|
||||
<div className={`flex items-center gap-2 px-4 py-2 rounded-full text-sm font-medium ${
|
||||
step === n
|
||||
? "bg-stone-900 text-white"
|
||||
: done
|
||||
? "bg-stone-200 text-stone-700"
|
||||
: "bg-zinc-950 text-slate-400"
|
||||
}`}>
|
||||
<span className={`w-5 h-5 rounded-full flex items-center justify-center text-xs border ${
|
||||
step === n ? "border-white" : done ? "border-stone-400" : "border-zinc-600"
|
||||
}`}>
|
||||
{done ? (
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
) : n}
|
||||
</span>
|
||||
<span className="hidden sm:inline">{label}</span>
|
||||
</div>
|
||||
{i < STEPS.length - 1 && (
|
||||
<div className={`h-0.5 w-6 ${done ? "bg-stone-300" : "bg-slate-200"} mx-1`} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Step content card */}
|
||||
<div className="bg-zinc-900 rounded-xl border border-zinc-800 p-6">
|
||||
|
||||
{/* Step 1: Template */}
|
||||
{step === 1 && (
|
||||
<div className="flex flex-col gap-5">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-zinc-100">Choose a Template</h2>
|
||||
<p className="text-sm text-zinc-500 mt-0.5">Select a template or start from scratch.</p>
|
||||
</div>
|
||||
|
||||
{templates.length === 0 ? (
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-sm text-zinc-500">No templates yet. Create your subject and body below.</p>
|
||||
<div className="flex flex-col gap-3">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Subject line"
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
className="w-full border border-zinc-600 rounded-lg px-3 py-2.5 text-sm outline-none focus:border-slate-900"
|
||||
/>
|
||||
<textarea
|
||||
placeholder="Email body text…"
|
||||
value={bodyText}
|
||||
onChange={(e) => setBodyText(e.target.value)}
|
||||
rows={5}
|
||||
className="w-full border border-zinc-600 rounded-lg px-3 py-2.5 text-sm resize-none outline-none focus:border-slate-900"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={() => setStep(2)}
|
||||
className="px-5 py-2.5 rounded-lg bg-stone-900 text-white text-sm font-medium hover:bg-stone-800"
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{/* Start from scratch */}
|
||||
<button
|
||||
onClick={() => setStep(2)}
|
||||
className="rounded-xl border-2 border-dashed border-zinc-600 p-5 text-left hover:border-stone-400 hover:bg-zinc-950 transition-all group"
|
||||
>
|
||||
<span className="text-2xl block mb-2">+</span>
|
||||
<p className="text-sm font-medium text-zinc-300 group-hover:text-zinc-100">Start from scratch</p>
|
||||
<p className="text-xs text-slate-400 mt-0.5">Write your own subject and body</p>
|
||||
</button>
|
||||
|
||||
{/* Template cards */}
|
||||
{templates.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => handleTemplateSelect(t)}
|
||||
className={`rounded-xl border p-4 text-left transition-all ${
|
||||
selectedTemplateId === t.id
|
||||
? "border-stone-900 bg-zinc-950"
|
||||
: "border-zinc-800 hover:border-slate-400 hover:bg-zinc-800"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-slate-800 truncate">{t.name}</p>
|
||||
<p className="text-xs text-slate-400 mt-0.5 truncate">{t.subject}</p>
|
||||
</div>
|
||||
<span className="inline-flex items-center rounded-full bg-zinc-950 px-2 py-0.5 text-xs text-zinc-400 flex-shrink-0">
|
||||
{t.template_type}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: Audience */}
|
||||
{step === 2 && (
|
||||
<div className="flex flex-col gap-5">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-zinc-100">Audience</h2>
|
||||
<p className="text-sm text-zinc-500 mt-0.5">Name your campaign and pick a segment.</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300 mb-1.5">Campaign Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g. May Sweet Corn Promotion"
|
||||
className="w-full border border-zinc-600 rounded-lg px-3 py-2.5 text-sm outline-none focus:border-slate-900"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300 mb-1.5">Saved Segment</label>
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
value={selectedSegmentId}
|
||||
onChange={(e) => {
|
||||
setSelectedSegmentId(e.target.value);
|
||||
}}
|
||||
className="flex-1 border border-zinc-600 rounded-lg px-3 py-2.5 text-sm bg-zinc-900 outline-none focus:border-slate-900"
|
||||
>
|
||||
<option value="">— Choose a saved segment —</option>
|
||||
{segments.map((s) => (
|
||||
<option key={s.id} value={s.id}>{s.name}</option>
|
||||
))}
|
||||
</select>
|
||||
{selectedSegmentId && (
|
||||
<button
|
||||
onClick={() => setSelectedSegmentId("")}
|
||||
className="px-3 text-xs text-slate-400 hover:text-red-500 border border-zinc-800 rounded-lg hover:border-red-200"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-red-400">{error}</p>}
|
||||
|
||||
<div className="flex justify-between pt-2">
|
||||
<button
|
||||
onClick={() => setStep(1)}
|
||||
className="px-4 py-2.5 text-sm text-zinc-500 hover:text-zinc-300 flex items-center gap-1"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" /></svg>
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStep(3)}
|
||||
disabled={!name}
|
||||
className="px-5 py-2.5 rounded-lg bg-stone-900 text-white text-sm font-medium hover:bg-stone-800 disabled:opacity-40"
|
||||
>
|
||||
Continue to Preview
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3: Preview */}
|
||||
{step === 3 && (
|
||||
<div className="flex flex-col gap-5">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-zinc-100">Preview & Edit</h2>
|
||||
<p className="text-sm text-zinc-500 mt-0.5">Review how your message will look.</p>
|
||||
</div>
|
||||
|
||||
{/* Mock email frame */}
|
||||
<div className="rounded-xl border border-zinc-800 bg-slate-50 overflow-hidden">
|
||||
<div className="px-4 py-3 bg-zinc-900 border-b border-zinc-800 flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-stone-300" />
|
||||
<div>
|
||||
<p className="text-xs font-medium text-zinc-300">Your Brand</p>
|
||||
<p className="text-xs text-slate-400">To: {"{{customer_name}}"}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-5">
|
||||
<p className="text-base font-semibold text-zinc-100 mb-3">{subject || "(no subject)"}</p>
|
||||
<div className="text-sm text-zinc-300 whitespace-pre-wrap">{bodyText || "(no body)"}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Editable subject */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-zinc-500 uppercase tracking-wide mb-1.5">Subject Line</label>
|
||||
<input
|
||||
type="text"
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
className="w-full border border-zinc-600 rounded-lg px-3 py-2.5 text-sm outline-none focus:border-slate-900"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between pt-2">
|
||||
<button
|
||||
onClick={() => setStep(2)}
|
||||
className="px-4 py-2.5 text-sm text-zinc-500 hover:text-zinc-300 flex items-center gap-1"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" /></svg>
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStep(4)}
|
||||
className="px-5 py-2.5 rounded-lg bg-stone-900 text-white text-sm font-medium hover:bg-stone-800"
|
||||
>
|
||||
Continue to Schedule
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 4: Schedule */}
|
||||
{step === 4 && (
|
||||
<div className="flex flex-col gap-5">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-zinc-100">Schedule & Send</h2>
|
||||
<p className="text-sm text-zinc-500 mt-0.5">Set campaign type and timing.</p>
|
||||
</div>
|
||||
|
||||
{/* Campaign type */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-zinc-500 uppercase tracking-wide mb-2">Campaign Type</label>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{CAMPAIGN_TYPES.map((ct) => (
|
||||
<button
|
||||
key={ct.value}
|
||||
onClick={() => setCampaignType(ct.value)}
|
||||
className={`rounded-xl border py-3 px-4 text-left transition-all ${
|
||||
campaignType === ct.value
|
||||
? "border-stone-900 bg-stone-900 text-white"
|
||||
: "border-zinc-800 hover:border-slate-400 hover:bg-zinc-800"
|
||||
}`}
|
||||
>
|
||||
<p className={`text-sm font-medium ${campaignType === ct.value ? "text-white" : "text-zinc-300"}`}>{ct.label}</p>
|
||||
<p className={`text-xs mt-0.5 ${campaignType === ct.value ? "text-stone-300" : "text-slate-400"}`}>{ct.desc}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Send timing */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-zinc-500 uppercase tracking-wide mb-2">Send</label>
|
||||
<div className="flex gap-6">
|
||||
<label className="flex items-center gap-2.5 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
checked={sendNow}
|
||||
onChange={() => setSendNow(true)}
|
||||
className="accent-stone-900 w-4 h-4"
|
||||
/>
|
||||
<span className="text-sm font-medium text-zinc-300">Send now</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2.5 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
checked={!sendNow}
|
||||
onChange={() => setSendNow(false)}
|
||||
className="accent-stone-900 w-4 h-4"
|
||||
/>
|
||||
<span className="text-sm font-medium text-zinc-300">Schedule for later</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!sendNow && (
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-zinc-500 uppercase tracking-wide mb-1.5">Send Date & Time</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={scheduledAt}
|
||||
onChange={(e) => setScheduledAt(e.target.value)}
|
||||
className="border border-zinc-600 rounded-lg px-3 py-2.5 text-sm outline-none focus:border-slate-900"
|
||||
min={new Date().toISOString().slice(0, 16)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Summary */}
|
||||
<div className="rounded-xl bg-slate-50 border border-zinc-800 p-4">
|
||||
<p className="text-xs font-medium text-zinc-500 uppercase tracking-wide mb-3">Summary</p>
|
||||
<div className="grid grid-cols-2 gap-y-2 text-sm">
|
||||
<span className="text-zinc-500">Campaign</span>
|
||||
<span className="text-zinc-100 font-medium">{name || "—"}</span>
|
||||
<span className="text-zinc-500">Template</span>
|
||||
<span className="text-zinc-100">{selectedTemplate?.name ?? "Custom"}</span>
|
||||
<span className="text-zinc-500">Audience</span>
|
||||
<span className="text-zinc-100">{selectedSegment?.name ?? "—"}</span>
|
||||
<span className="text-zinc-500">Type</span>
|
||||
<span className="text-zinc-100 capitalize">{campaignType}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-red-400">{error}</p>}
|
||||
|
||||
<div className="flex justify-between pt-2">
|
||||
<button
|
||||
onClick={() => setStep(3)}
|
||||
className="px-4 py-2.5 text-sm text-zinc-500 hover:text-zinc-300 flex items-center gap-1"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" /></svg>
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCreateOrSchedule}
|
||||
disabled={saving || (!sendNow && !scheduledAt)}
|
||||
className="px-5 py-2.5 rounded-lg bg-stone-900 text-white text-sm font-medium hover:bg-stone-800 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{saving ? "Processing…" : sendNow ? "Send Campaign" : "Schedule Campaign"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Recent campaigns */}
|
||||
{campaigns.length > 0 && (
|
||||
<div className="bg-zinc-900 rounded-xl border border-zinc-800 overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-zinc-800">
|
||||
<h3 className="text-sm font-semibold text-slate-800">Recent Campaigns</h3>
|
||||
</div>
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-50 border-b border-zinc-800">
|
||||
<tr>
|
||||
{["Name", "Type", "Status", "Sent"].map((h) => (
|
||||
<th key={h} className={`text-left px-6 py-3 font-medium text-zinc-400 ${h !== "Name" ? "hidden sm:table-cell" : ""}`}>{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{campaigns.slice(0, 10).map((c) => (
|
||||
<tr key={c.id} className="hover:bg-zinc-800 transition-colors">
|
||||
<td className="px-6 py-3.5 font-medium text-slate-800">{c.name}</td>
|
||||
<td className="px-6 py-3.5 hidden sm:table-cell text-zinc-500 capitalize">{c.campaign_type}</td>
|
||||
<td className="px-6 py-3.5 hidden sm:table-cell">{statusBadge(c.status)}</td>
|
||||
<td className="px-6 py-3.5 hidden sm:table-cell text-xs text-slate-400">
|
||||
{c.sent_at ? new Date(c.sent_at).toLocaleDateString() : "—"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
const TABS = [
|
||||
{ href: "/admin/harvest-reach/segments", label: "Segments" },
|
||||
{ href: "/admin/harvest-reach/campaigns", label: "Campaigns" },
|
||||
{ href: "/admin/harvest-reach/analytics", label: "Analytics" },
|
||||
];
|
||||
|
||||
export default function HarvestReachNav() {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<nav className="flex gap-1 border-b border-zinc-800 mb-6">
|
||||
{TABS.map((tab) => {
|
||||
const active = pathname.startsWith(tab.href);
|
||||
return (
|
||||
<Link
|
||||
key={tab.href}
|
||||
href={tab.href}
|
||||
className={`px-4 py-2.5 text-sm font-medium border-b-2 transition-colors ${
|
||||
active
|
||||
? "border-stone-900 text-stone-900"
|
||||
: "border-transparent text-zinc-500 hover:text-zinc-300"
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { type SegmentRuleV2, type PreviewResult } from "@/actions/harvest-reach/segments";
|
||||
|
||||
type Props = {
|
||||
brandId: string;
|
||||
rules: SegmentRuleV2;
|
||||
};
|
||||
|
||||
const DEBOUNCE_MS = 300;
|
||||
|
||||
export default function MatchingCustomersPanel({ brandId, rules }: Props) {
|
||||
const [preview, setPreview] = useState<PreviewResult | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const [page, setPage] = useState(0);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (rules.filters.length === 0) {
|
||||
setPreview(null);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = setTimeout(async () => {
|
||||
const { previewSegmentWithCustomers } = await import("@/actions/harvest-reach/segments");
|
||||
const result = await previewSegmentWithCustomers(brandId, rules);
|
||||
setPreview(result);
|
||||
setLoading(false);
|
||||
setPage(0);
|
||||
}, DEBOUNCE_MS);
|
||||
return () => clearTimeout(timerRef.current);
|
||||
}, [brandId, rules]);
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
const filtered = preview?.sample_customers ?? [];
|
||||
const searched = search
|
||||
? filtered.filter(
|
||||
(c) =>
|
||||
c.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
c.email.toLowerCase().includes(search.toLowerCase())
|
||||
)
|
||||
: filtered;
|
||||
const total = preview?.count ?? 0;
|
||||
const paged = searched.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
|
||||
|
||||
return (
|
||||
<div className="bg-zinc-900 rounded-xl border border-zinc-800 p-5 flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-slate-800">Matching Customers</h3>
|
||||
{preview && (
|
||||
<span className="inline-flex items-center rounded-full bg-stone-100 px-2.5 py-0.5 text-xs font-medium text-stone-700">
|
||||
{total.toLocaleString()} total
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{rules.filters.length === 0 ? (
|
||||
<div className="flex-1 flex items-center justify-center py-12">
|
||||
<p className="text-sm text-slate-400 text-center">
|
||||
Add filters to see matching customers
|
||||
</p>
|
||||
</div>
|
||||
) : loading ? (
|
||||
<div className="flex-1 flex items-center justify-center py-12">
|
||||
<div className="flex items-center gap-2.5 text-slate-400">
|
||||
<svg className="w-4 h-4 animate-spin" 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">Loading…</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search by name or email…"
|
||||
value={search}
|
||||
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
|
||||
className="border border-zinc-800 rounded-lg px-3 py-2 text-sm outline-none focus:border-slate-900"
|
||||
/>
|
||||
|
||||
{paged.length === 0 ? (
|
||||
<p className="text-sm text-slate-400 text-center py-6">No customers match these filters.</p>
|
||||
) : (
|
||||
<div className="flex-1 overflow-y-auto max-h-[460px] flex flex-col gap-1">
|
||||
{paged.map((c) => (
|
||||
<div
|
||||
key={c.id}
|
||||
className="flex items-center gap-3 px-3 py-2.5 rounded-lg hover:bg-zinc-800 transition-colors"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-full bg-stone-200 flex items-center justify-center text-xs font-medium text-stone-600 flex-shrink-0">
|
||||
{c.name ? c.name.slice(0, 2).toUpperCase() : "??"}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-slate-800 truncate">{c.name || "(no name)"}</p>
|
||||
<p className="text-xs text-slate-400 truncate">{c.email}</p>
|
||||
</div>
|
||||
{c.tags.length > 0 && (
|
||||
<div className="flex gap-1 flex-shrink-0">
|
||||
{c.tags.slice(0, 2).map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="inline-flex items-center rounded-full bg-blue-900/30 text-blue-400 px-2 py-0.5 text-xs"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{searched.length > PAGE_SIZE && (
|
||||
<div className="flex items-center justify-between pt-3 border-t border-slate-100">
|
||||
<span className="text-xs text-slate-400">
|
||||
Showing {page * PAGE_SIZE + 1}–{Math.min((page + 1) * PAGE_SIZE, searched.length)} of {searched.length}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||
disabled={page === 0}
|
||||
className="px-3 py-1 text-xs rounded-lg border border-zinc-800 text-zinc-400 hover:bg-zinc-800 disabled:opacity-40"
|
||||
>
|
||||
Prev
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
disabled={(page + 1) * PAGE_SIZE >= searched.length}
|
||||
className="px-3 py-1 text-xs rounded-lg border border-zinc-800 text-zinc-400 hover:bg-zinc-800 disabled:opacity-40"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { type Segment, type SegmentRuleV2 } from "@/actions/harvest-reach/segments";
|
||||
import SegmentBuilderPanel from "./SegmentBuilderPanel";
|
||||
import MatchingCustomersPanel from "./MatchingCustomersPanel";
|
||||
import SegmentListSidebar from "./SegmentListSidebar";
|
||||
import SegmentEditModal from "./SegmentEditModal";
|
||||
|
||||
type Props = {
|
||||
brandId: string;
|
||||
initialSegments: Segment[];
|
||||
};
|
||||
|
||||
export default function SegmentBuilderPage({ brandId, initialSegments }: Props) {
|
||||
const [segments, setSegments] = useState<Segment[]>(initialSegments);
|
||||
const [activeSegment, setActiveSegment] = useState<Segment | null>(null);
|
||||
const [currentRules, setCurrentRules] = useState<SegmentRuleV2>({
|
||||
combinator: "AND",
|
||||
filters: [],
|
||||
});
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
|
||||
function handleSegmentSelect(segment: Segment) {
|
||||
setActiveSegment(segment);
|
||||
setCurrentRules(segment.rules as SegmentRuleV2);
|
||||
}
|
||||
|
||||
function handleNewSegment() {
|
||||
setActiveSegment(null);
|
||||
setCurrentRules({ combinator: "AND", filters: [] });
|
||||
}
|
||||
|
||||
function handleRulesChange(rules: SegmentRuleV2) {
|
||||
setCurrentRules(rules);
|
||||
setActiveSegment(null);
|
||||
}
|
||||
|
||||
async function handleSaveSegment(name: string, description: string) {
|
||||
const { upsertHarvestReachSegment } = await import("@/actions/harvest-reach/segments");
|
||||
const result = await upsertHarvestReachSegment({
|
||||
id: activeSegment?.id,
|
||||
brand_id: brandId,
|
||||
name,
|
||||
description,
|
||||
rules: currentRules,
|
||||
});
|
||||
if (result.success) {
|
||||
if (activeSegment) {
|
||||
setSegments((prev) =>
|
||||
prev.map((s) => (s.id === result.segment.id ? result.segment : s))
|
||||
);
|
||||
} else {
|
||||
setSegments((prev) => [...prev, result.segment]);
|
||||
}
|
||||
setActiveSegment(result.segment);
|
||||
setShowEditModal(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteSegment(segmentId: string) {
|
||||
const { deleteHarvestReachSegment } = await import("@/actions/harvest-reach/segments");
|
||||
await deleteHarvestReachSegment(segmentId, brandId);
|
||||
setSegments((prev) => prev.filter((s) => s.id !== segmentId));
|
||||
if (activeSegment?.id === segmentId) {
|
||||
handleNewSegment();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Page header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-zinc-100">Segment Builder</h2>
|
||||
<p className="text-sm text-zinc-500 mt-0.5">
|
||||
Build filters to define your audience, then save and reuse the segment.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main layout */}
|
||||
<div className="flex gap-5">
|
||||
{/* Left sidebar */}
|
||||
<div className="w-72 flex-shrink-0">
|
||||
<SegmentListSidebar
|
||||
segments={segments}
|
||||
activeSegmentId={activeSegment?.id}
|
||||
onSelect={handleSegmentSelect}
|
||||
onNew={handleNewSegment}
|
||||
onDelete={handleDeleteSegment}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Main content: builder + preview */}
|
||||
<div className="flex-1 grid grid-cols-2 gap-5 min-h-[580px]">
|
||||
<SegmentBuilderPanel
|
||||
brandId={brandId}
|
||||
rules={currentRules}
|
||||
onChange={handleRulesChange}
|
||||
onSave={() => setShowEditModal(true)}
|
||||
hasActiveSegment={!!activeSegment}
|
||||
/>
|
||||
<MatchingCustomersPanel brandId={brandId} rules={currentRules} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showEditModal && (
|
||||
<SegmentEditModal
|
||||
initialName={activeSegment?.name ?? ""}
|
||||
initialDescription={activeSegment?.description ?? ""}
|
||||
onSave={handleSaveSegment}
|
||||
onClose={() => setShowEditModal(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import {
|
||||
type SegmentRuleV2,
|
||||
type SegmentFilter,
|
||||
type SegmentFilterType,
|
||||
type SegmentFilterParams,
|
||||
} from "@/actions/harvest-reach/segments";
|
||||
|
||||
type Props = {
|
||||
brandId: string;
|
||||
rules: SegmentRuleV2;
|
||||
onChange: (rules: SegmentRuleV2) => void;
|
||||
onSave: () => void;
|
||||
hasActiveSegment: boolean;
|
||||
};
|
||||
|
||||
const FILTER_TYPES: { value: SegmentFilterType; label: string }[] = [
|
||||
{ value: "all_customers", label: "All Customers" },
|
||||
{ value: "stop", label: "Past Stop" },
|
||||
{ value: "upcoming_stop", label: "Upcoming Stop" },
|
||||
{ value: "product", label: "Product Purchased" },
|
||||
{ value: "zip_code", label: "ZIP / City" },
|
||||
{ value: "customer_history", label: "Order History" },
|
||||
{ value: "tags", label: "Tags" },
|
||||
];
|
||||
|
||||
const ORDER_HISTORY_OPTIONS = [
|
||||
{ value: "all", label: "All customers" },
|
||||
{ value: "first_order", label: "First-time buyers" },
|
||||
{ value: "repeat", label: "Repeat buyers" },
|
||||
];
|
||||
|
||||
function emptyFilter(type: SegmentFilterType): SegmentFilter {
|
||||
const params: SegmentFilterParams = {};
|
||||
if (type === "product") params.days_back = 90;
|
||||
if (type === "customer_history") { params.order_history = "all"; params.days_back = 90; }
|
||||
if (type === "stop" || type === "upcoming_stop") { params.date_from = ""; params.date_to = ""; }
|
||||
if (type === "zip_code") { params.zip_codes = []; params.city = ""; }
|
||||
if (type === "tags") params.tags = [];
|
||||
return { type, params };
|
||||
}
|
||||
|
||||
export default function SegmentBuilderPanel({ brandId, rules, onChange, onSave, hasActiveSegment }: Props) {
|
||||
const setCombinator = useCallback((combinator: "AND" | "OR") => {
|
||||
onChange({ ...rules, combinator });
|
||||
}, [rules, onChange]);
|
||||
|
||||
function updateFilter(index: number, updates: Partial<SegmentFilter>) {
|
||||
const newFilters = rules.filters.map((f, i) =>
|
||||
i === index ? { ...f, ...updates, params: { ...f.params, ...(updates.params ?? {}) } } : f
|
||||
);
|
||||
onChange({ ...rules, filters: newFilters });
|
||||
}
|
||||
|
||||
function removeFilter(index: number) {
|
||||
onChange({ ...rules, filters: rules.filters.filter((_, i) => i !== index) });
|
||||
}
|
||||
|
||||
function addFilter(type: SegmentFilterType) {
|
||||
onChange({ ...rules, filters: [...rules.filters, emptyFilter(type)] });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-zinc-900 rounded-xl border border-zinc-800 p-5 flex flex-col gap-5">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-slate-800">Filter Rules</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-zinc-500">Match</span>
|
||||
<div className="flex rounded-lg border border-zinc-800 bg-slate-50 p-0.5">
|
||||
{(["AND", "OR"] as const).map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setCombinator(c)}
|
||||
className={`px-3 py-1 text-xs font-medium rounded-md transition-colors ${
|
||||
rules.combinator === c
|
||||
? "bg-stone-900 text-white"
|
||||
: "text-zinc-400 hover:bg-zinc-900"
|
||||
}`}
|
||||
>
|
||||
{c}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-xs text-zinc-500">of the following</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter blocks */}
|
||||
<div className="flex flex-col gap-3">
|
||||
{rules.filters.length === 0 && (
|
||||
<div className="py-8 text-center">
|
||||
<p className="text-sm text-slate-400">
|
||||
No filters yet. Add a filter below to start building your segment.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{rules.filters.map((filter, index) => (
|
||||
<FilterBlock
|
||||
key={index}
|
||||
brandId={brandId}
|
||||
filter={filter}
|
||||
onChange={(updates) => updateFilter(index, updates)}
|
||||
onRemove={() => removeFilter(index)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Add filter chips */}
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{FILTER_TYPES.map((ft) => (
|
||||
<button
|
||||
key={ft.value}
|
||||
onClick={() => addFilter(ft.value)}
|
||||
className="px-3 py-1.5 text-xs font-medium rounded-full border border-zinc-600 text-zinc-400 hover:bg-zinc-800 hover:border-slate-400 transition-colors"
|
||||
>
|
||||
+ {ft.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Save button */}
|
||||
<div className="pt-1 border-t border-slate-100">
|
||||
<button
|
||||
onClick={onSave}
|
||||
disabled={rules.filters.length === 0}
|
||||
className="w-full py-2.5 rounded-lg bg-stone-900 text-white text-sm font-medium hover:bg-stone-800 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
{hasActiveSegment ? "Update Segment" : "Save Segment"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Filter Block ─────────────────────────────────────────────
|
||||
|
||||
type FilterBlockProps = {
|
||||
brandId: string;
|
||||
filter: SegmentFilter;
|
||||
onChange: (updates: Partial<SegmentFilter>) => void;
|
||||
onRemove: () => void;
|
||||
};
|
||||
|
||||
function FilterBlock({ brandId, filter, onChange, onRemove }: FilterBlockProps) {
|
||||
const [products, setProducts] = useState<{ id: string; name: string }[]>([]);
|
||||
const [stops, setStops] = useState<{ id: string; city: string; date: string }[]>([]);
|
||||
const [productsLoaded, setProductsLoaded] = useState(false);
|
||||
const [stopsLoaded, setStopsLoaded] = useState(false);
|
||||
|
||||
function loadProducts() {
|
||||
if (productsLoaded) return;
|
||||
import("@/actions/harvest-reach/products").then((m) => {
|
||||
m.getProductsForSegmentPicker(brandId).then((data) => {
|
||||
setProducts(data);
|
||||
setProductsLoaded(true);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function loadStops(past: boolean) {
|
||||
if (stopsLoaded) return;
|
||||
import("@/actions/harvest-reach/stops").then((m) => {
|
||||
m.getStopsForSegmentPicker(brandId).then((data) => {
|
||||
setStops(data.filter((s) => past ? s.is_past : s.is_upcoming));
|
||||
setStopsLoaded(true);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-zinc-800 p-4 flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<select
|
||||
value={filter.type}
|
||||
onChange={(e) =>
|
||||
onChange({ type: e.target.value as SegmentFilterType, params: emptyFilter(e.target.value as SegmentFilterType).params })
|
||||
}
|
||||
className="text-sm font-medium border border-zinc-800 rounded-lg px-2.5 py-1.5 bg-zinc-900 outline-none focus:border-slate-900"
|
||||
>
|
||||
{FILTER_TYPES.map((ft) => (
|
||||
<option key={ft.value} value={ft.value}>{ft.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={onRemove}
|
||||
className="text-slate-400 hover:text-red-500 transition-colors"
|
||||
aria-label="Remove filter"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{/* Stop / Upcoming Stop */}
|
||||
{(filter.type === "stop" || filter.type === "upcoming_stop") && (
|
||||
<>
|
||||
<select
|
||||
value={filter.params.stop_id ?? ""}
|
||||
onChange={(e) => onChange({ params: { ...filter.params, stop_id: e.target.value } })}
|
||||
onMouseEnter={() => loadStops(filter.type === "stop")}
|
||||
className="border border-zinc-800 rounded-lg px-3 py-2 text-sm bg-zinc-900 w-full outline-none focus:border-slate-900"
|
||||
>
|
||||
<option value="">Select {filter.type === "stop" ? "past" : "upcoming"} stop…</option>
|
||||
{stops.map((s) => (
|
||||
<option key={s.id} value={s.id}>{s.city} — {s.date}</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<input
|
||||
type="date"
|
||||
value={filter.params.date_from ?? ""}
|
||||
onChange={(e) => onChange({ params: { ...filter.params, date_from: e.target.value } })}
|
||||
className="border border-zinc-800 rounded-lg px-3 py-2 text-sm outline-none focus:border-slate-900"
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
value={filter.params.date_to ?? ""}
|
||||
onChange={(e) => onChange({ params: { ...filter.params, date_to: e.target.value } })}
|
||||
className="border border-zinc-800 rounded-lg px-3 py-2 text-sm outline-none focus:border-slate-900"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Product */}
|
||||
{filter.type === "product" && (
|
||||
<>
|
||||
<select
|
||||
value={filter.params.product_id ?? ""}
|
||||
onChange={(e) => {
|
||||
onChange({ params: { ...filter.params, product_id: e.target.value } });
|
||||
if (e.target.value) loadProducts();
|
||||
}}
|
||||
onMouseEnter={loadProducts}
|
||||
className="border border-zinc-800 rounded-lg px-3 py-2 text-sm bg-zinc-900 w-full outline-none focus:border-slate-900"
|
||||
>
|
||||
<option value="">Select a product…</option>
|
||||
{products.map((p) => (
|
||||
<option key={p.id} value={p.id}>{p.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-zinc-500">In the last</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={365}
|
||||
value={filter.params.days_back ?? 90}
|
||||
onChange={(e) => onChange({ params: { ...filter.params, days_back: parseInt(e.target.value) } })}
|
||||
className="border border-zinc-800 rounded-lg px-2.5 py-1.5 text-sm w-20 outline-none focus:border-slate-900"
|
||||
/>
|
||||
<span className="text-xs text-zinc-500">days</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ZIP / City */}
|
||||
{filter.type === "zip_code" && (
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="ZIP codes (comma-separated)"
|
||||
value={(filter.params.zip_codes ?? []).join(", ")}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
params: {
|
||||
...filter.params,
|
||||
zip_codes: e.target.value.split(",").map((z) => z.trim()).filter(Boolean),
|
||||
},
|
||||
})
|
||||
}
|
||||
className="border border-zinc-800 rounded-lg px-3 py-2 text-sm outline-none focus:border-slate-900"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="City (optional)"
|
||||
value={filter.params.city ?? ""}
|
||||
onChange={(e) => onChange({ params: { ...filter.params, city: e.target.value } })}
|
||||
className="border border-zinc-800 rounded-lg px-3 py-2 text-sm outline-none focus:border-slate-900"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Customer History */}
|
||||
{filter.type === "customer_history" && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<select
|
||||
value={filter.params.order_history ?? "all"}
|
||||
onChange={(e) =>
|
||||
onChange({ params: { ...filter.params, order_history: e.target.value as "all" | "first_order" | "repeat" } })
|
||||
}
|
||||
className="border border-zinc-800 rounded-lg px-3 py-2 text-sm bg-zinc-900 outline-none focus:border-slate-900"
|
||||
>
|
||||
{ORDER_HISTORY_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-zinc-500">In the last</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={365}
|
||||
value={filter.params.days_back ?? 90}
|
||||
onChange={(e) => onChange({ params: { ...filter.params, days_back: parseInt(e.target.value) } })}
|
||||
className="border border-zinc-800 rounded-lg px-2.5 py-1.5 text-sm w-20 outline-none focus:border-slate-900"
|
||||
/>
|
||||
<span className="text-xs text-zinc-500">days</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tags */}
|
||||
{filter.type === "tags" && (
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Tags (comma-separated)"
|
||||
value={(filter.params.tags ?? []).join(", ")}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
params: {
|
||||
...filter.params,
|
||||
tags: e.target.value.split(",").map((t) => t.trim()).filter(Boolean),
|
||||
},
|
||||
})
|
||||
}
|
||||
className="border border-zinc-800 rounded-lg px-3 py-2 text-sm outline-none focus:border-slate-900"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* All customers */}
|
||||
{filter.type === "all_customers" && (
|
||||
<p className="text-xs text-slate-400">Matches all customers in your brand.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
type Props = {
|
||||
initialName?: string;
|
||||
initialDescription?: string;
|
||||
onSave: (name: string, description: string) => Promise<void>;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export default function SegmentEditModal({ initialName = "", initialDescription = "", onSave, onClose }: Props) {
|
||||
const [name, setName] = useState(initialName);
|
||||
const [description, setDescription] = useState(initialDescription);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
async function handleSave() {
|
||||
if (!name.trim()) return;
|
||||
setSaving(true);
|
||||
await onSave(name.trim(), description.trim());
|
||||
setSaving(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||
<div className="bg-zinc-900 rounded-xl border border-zinc-800 w-full max-w-md p-6 flex flex-col gap-4 shadow-xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-base font-semibold text-zinc-100">
|
||||
{initialName ? "Update Segment" : "Save Segment"}
|
||||
</h2>
|
||||
<button onClick={onClose} className="text-slate-400 hover:text-zinc-400 transition-colors">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300 mb-1">Segment Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSave()}
|
||||
placeholder="e.g. Fort Pierce Regulars"
|
||||
className="w-full border border-zinc-600 rounded-lg px-3 py-2 text-sm"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300 mb-1">Description <span className="text-slate-400 font-normal">(optional)</span></label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="e.g. Customers who pick up at the Fort Pierce stop regularly"
|
||||
rows={3}
|
||||
className="w-full border border-zinc-600 rounded-lg px-3 py-2 text-sm resize-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-1 py-2.5 rounded-lg border border-zinc-600 text-sm font-medium text-zinc-400 hover:bg-zinc-800 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!name.trim() || saving}
|
||||
className="flex-1 py-2.5 rounded-lg bg-stone-900 text-white text-sm font-medium hover:bg-stone-800 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{saving ? "Saving…" : "Save Segment"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type { Segment } from "@/actions/harvest-reach/segments";
|
||||
|
||||
type Props = {
|
||||
segments: Segment[];
|
||||
activeSegmentId?: string;
|
||||
onSelect: (segment: Segment) => void;
|
||||
onNew: () => void;
|
||||
onDelete: (segmentId: string) => void;
|
||||
};
|
||||
|
||||
export default function SegmentListSidebar({ segments, activeSegmentId, onSelect, onNew, onDelete }: Props) {
|
||||
const [search, setSearch] = useState("");
|
||||
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
|
||||
|
||||
const filtered = segments.filter((s) =>
|
||||
s.name.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="bg-zinc-900 rounded-xl border border-zinc-800 p-4 flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-slate-800">Saved Segments</h3>
|
||||
<button
|
||||
onClick={onNew}
|
||||
className="w-7 h-7 rounded-lg bg-stone-900 text-white flex items-center justify-center text-base leading-none hover:bg-stone-800 transition-colors"
|
||||
aria-label="New segment"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search segments…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="border border-zinc-800 rounded-lg px-3 py-2 text-sm outline-none focus:border-slate-900"
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{filtered.length === 0 && (
|
||||
<p className="text-xs text-slate-400 text-center py-4">
|
||||
{search ? "No segments match." : "No saved segments yet."}
|
||||
</p>
|
||||
)}
|
||||
{filtered.map((segment) => (
|
||||
<div key={segment.id} className="group relative">
|
||||
{confirmDelete === segment.id ? (
|
||||
<div className="rounded-xl border border-red-200 bg-red-900/30 p-3 flex flex-col gap-2">
|
||||
<p className="text-xs text-red-400 font-medium">Delete “{segment.name}”?</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => { onDelete(segment.id); setConfirmDelete(null); }}
|
||||
className="flex-1 py-1.5 text-xs rounded-lg bg-red-600 text-white hover:bg-red-700 font-medium"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirmDelete(null)}
|
||||
className="flex-1 py-1.5 text-xs rounded-lg border border-zinc-600 bg-zinc-900 hover:bg-zinc-800 font-medium"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
onClick={() => onSelect(segment)}
|
||||
className={`px-3 py-2.5 rounded-xl cursor-pointer flex items-center justify-between gap-2 transition-all ${
|
||||
activeSegmentId === segment.id
|
||||
? "bg-stone-100 border border-stone-300"
|
||||
: "hover:bg-zinc-800 border border-transparent"
|
||||
}`}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-slate-800 truncate">{segment.name}</p>
|
||||
{segment.description && (
|
||||
<p className="text-xs text-slate-400 truncate mt-0.5">{segment.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setConfirmDelete(segment.id); }}
|
||||
className="opacity-0 group-hover:opacity-100 text-slate-400 hover:text-red-500 transition-all p-1"
|
||||
aria-label="Delete segment"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { updateWaterHeadgate, deleteWaterHeadgate } from "@/actions/water-log/admin";
|
||||
|
||||
type Headgate = {
|
||||
id: string;
|
||||
name: string;
|
||||
active: boolean;
|
||||
unit: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
const UNIT_OPTIONS = ["CFS", "GPM", "gal", "ac-in", "ac-ft"];
|
||||
|
||||
type Props = {
|
||||
headgate: Headgate;
|
||||
backHref?: string;
|
||||
};
|
||||
|
||||
export default function HeadgateEditForm({ headgate, backHref = "/admin/water-log" }: Props) {
|
||||
const router = useRouter();
|
||||
const [name, setName] = useState(headgate.name);
|
||||
const [active, setActive] = useState(headgate.active);
|
||||
const [unit, setUnit] = useState(headgate.unit);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleSave(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
const result = await updateWaterHeadgate(headgate.id, name.trim(), active, unit);
|
||||
if (result.success) {
|
||||
router.push(backHref);
|
||||
} else {
|
||||
setError(result.error ?? "Failed to save");
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!window.confirm(`Delete headgate "${headgate.name}"? Existing log entries will be preserved.`)) return;
|
||||
setDeleting(true);
|
||||
const result = await deleteWaterHeadgate(headgate.id);
|
||||
if (result.success) {
|
||||
router.push(backHref);
|
||||
} else {
|
||||
setError(result.error ?? "Failed to delete");
|
||||
setDeleting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSave} className="space-y-4">
|
||||
{error && (
|
||||
<div className="rounded-lg bg-red-900/30 border border-red-200 p-3 text-sm text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="col-span-2">
|
||||
<label className="block text-sm font-medium text-zinc-400 mb-1">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full rounded-lg border border-zinc-600 px-3 py-2 text-sm outline-none focus:border-slate-900"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-400 mb-1">Default Unit</label>
|
||||
<select
|
||||
value={unit}
|
||||
onChange={(e) => setUnit(e.target.value)}
|
||||
className="w-full rounded-lg border border-zinc-600 px-3 py-2 text-sm"
|
||||
>
|
||||
{UNIT_OPTIONS.map((u) => (
|
||||
<option key={u} value={u}>{u}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-400 mb-1">Status</label>
|
||||
<select
|
||||
value={active ? "1" : "0"}
|
||||
onChange={(e) => setActive(e.target.value === "1")}
|
||||
className="rounded-lg border border-zinc-600 px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="1">Active</option>
|
||||
<option value="0">Inactive</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDelete}
|
||||
disabled={deleting}
|
||||
className="rounded-lg border border-red-200 bg-zinc-900 px-4 py-2 text-sm font-medium text-red-400 hover:bg-red-900/30 disabled:opacity-50"
|
||||
>
|
||||
{deleting ? "..." : "Delete Headgate"}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="rounded-lg bg-green-600 px-6 py-2 text-sm font-medium text-white hover:bg-green-700 disabled:opacity-50"
|
||||
>
|
||||
{saving ? "..." : "Save Changes"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import AIProviderPanel from "@/components/admin/AIProviderPanel";
|
||||
import { savePaymentSettings } from "@/actions/payments";
|
||||
import { saveResendCredentials, saveTwilioCredentials, testResendConnection, testTwilioConnection, getResendCredentials, getTwilioCredentials } from "@/actions/integrations/credentials";
|
||||
|
||||
type CredentialField = {
|
||||
key: string;
|
||||
label: string;
|
||||
placeholder: string;
|
||||
isSecret?: boolean;
|
||||
};
|
||||
|
||||
type SyncOption = {
|
||||
key: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
type Integration = {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
description: string;
|
||||
accentColor: string;
|
||||
credentials: CredentialField[];
|
||||
syncOptions: SyncOption[];
|
||||
};
|
||||
|
||||
const INTEGRATIONS: Integration[] = [
|
||||
{
|
||||
id: "openai",
|
||||
name: "OpenAI",
|
||||
icon: "AI",
|
||||
description: "Power AI tools like Campaign Writer, Report Explainer, and Pricing Advisor.",
|
||||
accentColor: "border-violet-200",
|
||||
credentials: [
|
||||
{ key: "OPENAI_API_KEY", label: "API Key", placeholder: "sk-...", isSecret: true },
|
||||
{ key: "OPENAI_ORG_ID", label: "Organization ID", placeholder: "org-... (optional)" },
|
||||
],
|
||||
syncOptions: [
|
||||
{ key: "campaign_writer", label: "Campaign Idea Generator" },
|
||||
{ key: "product_writer", label: "Product Description Writer" },
|
||||
{ key: "report_explainer", label: "Report Explainer" },
|
||||
{ key: "pricing_advisor", label: "Pricing Advisor" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "resend",
|
||||
name: "Resend",
|
||||
icon: "Email",
|
||||
description: "Send transactional and marketing emails via Harvest Reach.",
|
||||
accentColor: "border-amber-200",
|
||||
credentials: [
|
||||
{ key: "RESEND_API_KEY", label: "API Key", placeholder: "re_...", isSecret: true },
|
||||
{ key: "RESEND_FROM_EMAIL", label: "From Email Address", placeholder: "orders@yourbrand.com" },
|
||||
{ key: "RESEND_FROM_NAME", label: "From Name", placeholder: "Your Brand Name" },
|
||||
],
|
||||
syncOptions: [
|
||||
{ key: "transactional", label: "Transactional Email" },
|
||||
{ key: "marketing", label: "Marketing Campaigns" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "stripe",
|
||||
name: "Stripe",
|
||||
icon: "Stripe",
|
||||
description: "Process online payments for orders.",
|
||||
accentColor: "border-stone-300",
|
||||
credentials: [
|
||||
{ key: "STRIPE_PUBLISHABLE_KEY", label: "Publishable Key", placeholder: "pk_live_..." },
|
||||
{ key: "STRIPE_SECRET_KEY", label: "Secret Key", placeholder: "sk_live_...", isSecret: true },
|
||||
{ key: "STRIPE_WEBHOOK_SECRET", label: "Webhook Secret", placeholder: "whsec_...", isSecret: true },
|
||||
],
|
||||
syncOptions: [
|
||||
{ key: "checkout", label: "Online Checkout" },
|
||||
{ key: "refunds", label: "Refund Processing" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "twilio",
|
||||
name: "Twilio",
|
||||
icon: "SMS",
|
||||
description: "Send SMS campaigns and alerts via Harvest Reach.",
|
||||
accentColor: "border-blue-200",
|
||||
credentials: [
|
||||
{ key: "TWILIO_ACCOUNT_SID", label: "Account SID", placeholder: "AC..." },
|
||||
{ key: "TWILIO_AUTH_TOKEN", label: "Auth Token", placeholder: "Your Twilio auth token", isSecret: true },
|
||||
{ key: "TWILIO_PHONE_NUMBER", label: "Phone Number", placeholder: "+1234567890" },
|
||||
],
|
||||
syncOptions: [
|
||||
{ key: "sms_campaigns", label: "SMS Campaigns" },
|
||||
{ key: "alerts", label: "Order Alerts" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const iconColors: Record<string, string> = {
|
||||
AI: "bg-violet-100 text-violet-700",
|
||||
Email: "bg-amber-100 text-amber-700",
|
||||
Stripe: "bg-stone-100 text-stone-700",
|
||||
SMS: "bg-blue-100 text-blue-700",
|
||||
};
|
||||
|
||||
function IntegrationCard({
|
||||
integration,
|
||||
initialCredentials,
|
||||
brandId,
|
||||
}: {
|
||||
integration: Integration;
|
||||
initialCredentials?: Record<string, string>;
|
||||
brandId: string;
|
||||
}) {
|
||||
const [credentials, setCredentials] = useState<Record<string, string>>(
|
||||
initialCredentials ?? {}
|
||||
);
|
||||
const [showSecrets, setShowSecrets] = useState<Record<string, boolean>>({});
|
||||
const [testResult, setTestResult] = useState<{ ok: boolean; message: string } | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleTest() {
|
||||
setTestResult(null);
|
||||
setError(null);
|
||||
|
||||
if (integration.id === "resend") {
|
||||
const apiKey = credentials["RESEND_API_KEY"];
|
||||
if (!apiKey?.trim()) {
|
||||
setTestResult({ ok: false, message: "API key is required" });
|
||||
return;
|
||||
}
|
||||
const result = await testResendConnection(apiKey);
|
||||
setTestResult(result);
|
||||
} else if (integration.id === "twilio") {
|
||||
const accountSid = credentials["TWILIO_ACCOUNT_SID"];
|
||||
const authToken = credentials["TWILIO_AUTH_TOKEN"];
|
||||
if (!accountSid?.trim() || !authToken?.trim()) {
|
||||
setTestResult({ ok: false, message: "Account SID and Auth Token are required" });
|
||||
return;
|
||||
}
|
||||
const result = await testTwilioConnection(accountSid, authToken);
|
||||
setTestResult(result);
|
||||
} else {
|
||||
// Default test (placeholder)
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
const hasKey = Object.values(credentials).some((v) => v.trim().length > 0);
|
||||
setTestResult(
|
||||
hasKey
|
||||
? { ok: true, message: `Successfully connected to ${integration.name}` }
|
||||
: { ok: false, message: `No API key configured for ${integration.name}` }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
setTestResult(null);
|
||||
|
||||
try {
|
||||
if (integration.id === "resend") {
|
||||
const result = await saveResendCredentials(brandId, {
|
||||
api_key: credentials["RESEND_API_KEY"]?.trim() || null,
|
||||
from_email: credentials["RESEND_FROM_EMAIL"]?.trim() || null,
|
||||
from_name: credentials["RESEND_FROM_NAME"]?.trim() || null,
|
||||
});
|
||||
if (!result.success) {
|
||||
setError(result.error ?? "Failed to save");
|
||||
return;
|
||||
}
|
||||
setTestResult({ ok: true, message: "Resend credentials saved successfully" });
|
||||
} else if (integration.id === "twilio") {
|
||||
const result = await saveTwilioCredentials(brandId, {
|
||||
account_sid: credentials["TWILIO_ACCOUNT_SID"]?.trim() || null,
|
||||
auth_token: credentials["TWILIO_AUTH_TOKEN"]?.trim() || null,
|
||||
phone_number: credentials["TWILIO_PHONE_NUMBER"]?.trim() || null,
|
||||
});
|
||||
if (!result.success) {
|
||||
setError(result.error ?? "Failed to save");
|
||||
return;
|
||||
}
|
||||
setTestResult({ ok: true, message: "Twilio credentials saved successfully" });
|
||||
} else if (integration.id === "stripe") {
|
||||
const result = await savePaymentSettings({
|
||||
brandId,
|
||||
provider: "stripe",
|
||||
stripePublishableKey: credentials["STRIPE_PUBLISHABLE_KEY"]?.trim() || undefined,
|
||||
stripeSecretKey: credentials["STRIPE_SECRET_KEY"]?.trim() || undefined,
|
||||
});
|
||||
if (!result.success) {
|
||||
setError(result.error ?? "Failed to save");
|
||||
return;
|
||||
}
|
||||
setTestResult({ ok: true, message: "Stripe credentials saved successfully" });
|
||||
} else {
|
||||
// Other integrations - just show success for now
|
||||
setTestResult({ ok: true, message: `${integration.name} settings saved` });
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to save settings");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSecret(key: string) {
|
||||
setShowSecrets((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`rounded-xl border p-5 bg-white shadow-sm ${integration.accentColor}`}>
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`w-10 h-10 rounded-lg flex items-center justify-center text-xs font-bold ${iconColors[integration.icon] ?? "bg-stone-100 text-stone-700"}`}>{integration.icon}</span>
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-stone-900">{integration.name}</h3>
|
||||
<p className="text-xs text-stone-500 mt-0.5">{integration.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 mb-4">
|
||||
{integration.credentials.map((field) => (
|
||||
<div key={field.key}>
|
||||
<label className="block text-xs font-medium text-stone-600 mb-1">{field.label}</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showSecrets[field.key] ? "text" : "password"}
|
||||
value={credentials[field.key] ?? ""}
|
||||
onChange={(e) => setCredentials((p) => ({ ...p, [field.key]: e.target.value }))}
|
||||
placeholder={field.placeholder}
|
||||
className="w-full rounded-lg border border-stone-200 bg-white px-3 py-2.5 text-sm pr-16 text-stone-900 placeholder:text-stone-400 outline-none focus:border-emerald-500"
|
||||
/>
|
||||
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex gap-1">
|
||||
{field.isSecret && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSecret(field.key)}
|
||||
className="text-xs text-stone-400 hover:text-stone-600 px-1"
|
||||
>
|
||||
{showSecrets[field.key] ? "Hide" : "Show"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{testResult && (
|
||||
<div className={`mb-4 rounded-lg px-4 py-2.5 text-sm flex items-center gap-2 ${
|
||||
testResult.ok ? "bg-emerald-50 text-emerald-700 border border-emerald-200" : "bg-red-50 text-red-700 border border-red-200"
|
||||
}`}>
|
||||
<span>{testResult.ok ? "✓" : "✗"}</span>
|
||||
<span>{testResult.message}</span>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="mb-4 rounded-lg px-4 py-2.5 text-sm bg-red-50 text-red-700 border border-red-200">{error}</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<button onClick={handleTest} className="rounded-lg border border-stone-200 px-4 py-2 text-xs font-medium text-stone-600 hover:bg-stone-50">Test Connection</button>
|
||||
<button onClick={handleSave} disabled={saving} className="flex-1 rounded-lg bg-stone-900 px-4 py-2 text-xs font-bold text-white hover:bg-stone-800 disabled:opacity-50">
|
||||
{saving ? "Saving..." : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type Props = {
|
||||
brandId: string;
|
||||
brands: { id: string; name: string }[];
|
||||
};
|
||||
|
||||
export default function IntegrationsInner({ brandId, brands }: Props) {
|
||||
const [selectedBrandId, setSelectedBrandId] = useState(brandId);
|
||||
const [initialCredentials, setInitialCredentials] = useState<Record<string, Record<string, string>>>({});
|
||||
|
||||
// Fetch initial credentials for each integration
|
||||
useEffect(() => {
|
||||
async function fetchCredentials() {
|
||||
const [resendCreds, twilioCreds] = await Promise.all([
|
||||
getResendCredentials(selectedBrandId),
|
||||
getTwilioCredentials(selectedBrandId),
|
||||
]);
|
||||
|
||||
setInitialCredentials({
|
||||
resend: {
|
||||
RESEND_API_KEY: resendCreds.api_key ?? "",
|
||||
RESEND_FROM_EMAIL: resendCreds.from_email ?? "",
|
||||
RESEND_FROM_NAME: resendCreds.from_name ?? "",
|
||||
},
|
||||
twilio: {
|
||||
TWILIO_ACCOUNT_SID: twilioCreds.account_sid ?? "",
|
||||
TWILIO_AUTH_TOKEN: twilioCreds.auth_token ?? "",
|
||||
TWILIO_PHONE_NUMBER: twilioCreds.phone_number ?? "",
|
||||
},
|
||||
});
|
||||
}
|
||||
fetchCredentials();
|
||||
}, [selectedBrandId]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* AI Provider — separate panel */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-base font-semibold text-stone-800">AI Provider</h3>
|
||||
<a href="/admin/settings/ai" className="text-xs text-emerald-600 hover:text-emerald-700 underline underline-offset-2">Configure AI →</a>
|
||||
</div>
|
||||
<AIProviderPanel brandId={selectedBrandId} />
|
||||
</div>
|
||||
|
||||
{/* Payment & email integrations grid */}
|
||||
<div className="space-y-3 mb-4">
|
||||
{INTEGRATIONS.filter(i => i.id !== "openai").map((integration) => (
|
||||
<IntegrationCard
|
||||
key={integration.id}
|
||||
integration={integration}
|
||||
initialCredentials={initialCredentials[integration.id]}
|
||||
brandId={selectedBrandId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { sendStopBlast } from "@/actions/communications/stop-blast";
|
||||
|
||||
type Order = {
|
||||
id: string;
|
||||
customer_name: string;
|
||||
customer_email: string | null;
|
||||
customer_phone: string | null;
|
||||
pickup_complete: boolean;
|
||||
};
|
||||
|
||||
type BlastMessage = {
|
||||
id: string;
|
||||
type: string;
|
||||
subject: string | null;
|
||||
body: string;
|
||||
created_at: string;
|
||||
message_recipients: { id: string }[];
|
||||
};
|
||||
|
||||
type MessageCustomersSectionProps = {
|
||||
stopId: string;
|
||||
brandId?: string;
|
||||
};
|
||||
|
||||
export default function MessageCustomersSection({
|
||||
stopId,
|
||||
brandId,
|
||||
}: MessageCustomersSectionProps) {
|
||||
const [orders, setOrders] = useState<Order[]>([]);
|
||||
const [messages, setMessages] = useState<BlastMessage[]>([]);
|
||||
const [loadingOrders, setLoadingOrders] = useState(true);
|
||||
const [loadingMessages, setLoadingMessages] = useState(true);
|
||||
|
||||
const [audience, setAudience] = useState<"all" | "pending" | "picked_up">("pending");
|
||||
const [channel, setChannel] = useState<"sms" | "email" | "both">("sms");
|
||||
const [subject, setSubject] = useState("");
|
||||
const [body, setBody] = useState("");
|
||||
const [sending, setSending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [confirm, setConfirm] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchOrders();
|
||||
fetchMessages();
|
||||
}, []);
|
||||
|
||||
async function fetchOrders() {
|
||||
setLoadingOrders(true);
|
||||
const res = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/orders?stop_id=eq.${stopId}&select=customer_name,customer_email,customer_phone,pickup_complete`,
|
||||
{
|
||||
headers: {
|
||||
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||
},
|
||||
}
|
||||
);
|
||||
const data = await res.json();
|
||||
if (res.ok) setOrders(data);
|
||||
setLoadingOrders(false);
|
||||
}
|
||||
|
||||
async function fetchMessages() {
|
||||
// Legacy messages
|
||||
const [msgRes, campaignRes] = await Promise.all([
|
||||
fetch(
|
||||
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/messages?stop_id=eq.${stopId}&select=*,message_recipients(id)&order=created_at.desc&limit=10`,
|
||||
{ headers: { apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! } }
|
||||
),
|
||||
// Campaign-based blast logs for this stop
|
||||
brandId
|
||||
? fetch(
|
||||
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/communication_campaigns?brand_id=eq.${brandId}&audience_rules->>'stop_id'=eq.${stopId}&order=created_at.desc&limit=5`,
|
||||
{ headers: { apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! } }
|
||||
)
|
||||
: Promise.resolve({ ok: false, json: async () => [] } as Response),
|
||||
]);
|
||||
|
||||
const legacyData = msgRes.ok ? await msgRes.json() : [];
|
||||
const campaignData = campaignRes.ok ? await campaignRes.json() : [];
|
||||
|
||||
// Normalize campaign logs into BlastMessage shape
|
||||
const campaignMessages: BlastMessage[] = (campaignData as Array<{
|
||||
id: string; subject: string | null; body_text: string | null;
|
||||
sent_at: string; created_at: string; status: string;
|
||||
}>).map((c) => ({
|
||||
id: c.id,
|
||||
type: "campaign",
|
||||
subject: c.subject,
|
||||
body: c.body_text ?? "",
|
||||
created_at: c.sent_at,
|
||||
message_recipients: [],
|
||||
}));
|
||||
|
||||
// Interleave: legacy first, then campaign-based
|
||||
const merged = [...legacyData, ...campaignMessages].sort(
|
||||
(a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
|
||||
);
|
||||
setMessages(merged.slice(0, 10));
|
||||
setLoadingMessages(false);
|
||||
}
|
||||
|
||||
const allOrders = orders;
|
||||
const pendingOrders = orders.filter((o) => !o.pickup_complete);
|
||||
const pickedUpOrders = orders.filter((o) => o.pickup_complete);
|
||||
|
||||
const filteredOrders = (() => {
|
||||
if (audience === "pending") return pendingOrders;
|
||||
if (audience === "picked_up") return pickedUpOrders;
|
||||
return allOrders;
|
||||
})();
|
||||
|
||||
const recipients = filteredOrders.filter((o) => {
|
||||
if (channel === "sms") return o.customer_phone;
|
||||
if (channel === "email") return o.customer_email;
|
||||
return o.customer_phone || o.customer_email;
|
||||
});
|
||||
|
||||
const showSubject = channel === "email" || channel === "both";
|
||||
|
||||
async function handleSend(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!body.trim()) return;
|
||||
if (showSubject && !subject.trim()) {
|
||||
setError("Subject is required for email or both.");
|
||||
return;
|
||||
}
|
||||
if (!brandId) {
|
||||
setError("Brand ID not available.");
|
||||
return;
|
||||
}
|
||||
|
||||
setSending(true);
|
||||
setError(null);
|
||||
setConfirm(null);
|
||||
|
||||
const result = await sendStopBlast({
|
||||
stopId,
|
||||
brandId,
|
||||
channel,
|
||||
subject: showSubject ? subject : undefined,
|
||||
body,
|
||||
audience,
|
||||
});
|
||||
|
||||
setSending(false);
|
||||
if (!result.success) {
|
||||
setError(result.error ?? "Failed to send blast");
|
||||
return;
|
||||
}
|
||||
setConfirm(`Blast sent — ${result.messages_logged} message${result.messages_logged !== 1 ? "s" : ""} logged via campaign.`);
|
||||
setBody("");
|
||||
setSubject("");
|
||||
fetchMessages();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{error && (
|
||||
<div className="rounded-xl bg-red-900/30 p-4 text-sm text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{confirm && (
|
||||
<div className="rounded-xl bg-green-900/30 p-4 text-sm text-green-400">
|
||||
{confirm}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loadingOrders ? (
|
||||
<p className="text-sm text-zinc-500">Loading orders...</p>
|
||||
) : orders.length === 0 ? (
|
||||
<p className="text-sm text-zinc-500">No orders for this stop yet.</p>
|
||||
) : (
|
||||
<>
|
||||
{/* Audience selector */}
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-zinc-300">
|
||||
Audience —{" "}
|
||||
<span className="text-zinc-500">
|
||||
{recipients.length} with contact info
|
||||
</span>
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
{(["all", "pending", "picked_up"] as const).map((a) => {
|
||||
const count =
|
||||
a === "all"
|
||||
? allOrders.length
|
||||
: a === "pending"
|
||||
? pendingOrders.length
|
||||
: pickedUpOrders.length;
|
||||
return (
|
||||
<button
|
||||
key={a}
|
||||
onClick={() => setAudience(a)}
|
||||
className={`flex-1 rounded-xl px-3 py-2 text-sm font-medium capitalize transition-colors ${
|
||||
audience === a
|
||||
? "bg-slate-900 text-white"
|
||||
: "bg-zinc-950 text-zinc-400 hover:bg-slate-200"
|
||||
}`}
|
||||
>
|
||||
{a.replace("_", " ")} ({count})
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Channel selector */}
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-zinc-300">
|
||||
Channel
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
{(["sms", "email", "both"] as const).map((ch) => (
|
||||
<button
|
||||
key={ch}
|
||||
onClick={() => setChannel(ch)}
|
||||
className={`flex-1 rounded-xl px-3 py-2 text-sm font-medium transition-colors ${
|
||||
channel === ch
|
||||
? "bg-slate-900 text-white"
|
||||
: "bg-zinc-950 text-zinc-400 hover:bg-slate-200"
|
||||
}`}
|
||||
>
|
||||
{ch === "sms" ? "📱 SMS" : ch === "email" ? "✉️ Email" : "📱+✉️ Both"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSend} className="space-y-4">
|
||||
{showSubject && (
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-zinc-300">
|
||||
Subject
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
placeholder="Important update about your pickup"
|
||||
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base outline-none focus:border-slate-900"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-zinc-300">
|
||||
Message
|
||||
</label>
|
||||
<textarea
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
rows={4}
|
||||
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base outline-none focus:border-slate-900"
|
||||
placeholder="Type your message here..."
|
||||
/>
|
||||
<p className="mt-1 text-xs text-slate-400">{body.length} characters</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!body.trim() || recipients.length === 0 || sending}
|
||||
className="w-full rounded-xl bg-slate-900 px-6 py-4 text-lg font-bold text-white disabled:opacity-50"
|
||||
>
|
||||
{sending
|
||||
? "Sending..."
|
||||
: `Send to ${recipients.length} customer${recipients.length !== 1 ? "s" : ""}`}
|
||||
</button>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Recent message history */}
|
||||
{!loadingMessages && messages.length > 0 && (
|
||||
<div>
|
||||
<p className="mb-3 text-sm font-medium text-zinc-300">
|
||||
Recent Messages
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{messages.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
className="rounded-xl border border-zinc-800 bg-slate-50 px-4 py-3"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium text-zinc-100">
|
||||
{msg.type.toUpperCase()}{" "}
|
||||
{msg.subject && <span className="text-zinc-500">— {msg.subject}</span>}
|
||||
</p>
|
||||
<p className="mt-1 truncate text-sm text-zinc-400">{msg.body}</p>
|
||||
<p className="mt-1 text-xs text-slate-400">
|
||||
{new Date(msg.created_at).toLocaleString()} ·{" "}
|
||||
{msg.message_recipients?.length ?? 0} recipients
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type { MessageLogEntry } from "@/actions/communications/send";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
queued: "bg-zinc-950 text-zinc-300",
|
||||
sent: "bg-blue-900/40 text-blue-700",
|
||||
delivered: "bg-emerald-100 text-emerald-700",
|
||||
opened: "bg-violet-100 text-violet-700",
|
||||
clicked: "bg-amber-100 text-amber-700",
|
||||
bounced: "bg-red-900/40 text-red-400",
|
||||
failed: "bg-red-900/40 text-red-400",
|
||||
unsubscribed: "bg-orange-100 text-orange-700",
|
||||
};
|
||||
|
||||
const METHOD_COLORS: Record<string, string> = {
|
||||
email: "bg-blue-900/40 text-blue-700",
|
||||
sms: "bg-green-900/40 text-green-400",
|
||||
push: "bg-purple-100 text-purple-700",
|
||||
internal: "bg-zinc-950 text-zinc-400",
|
||||
};
|
||||
|
||||
function EngagementBadges({ log }: { log: MessageLogEntry }) {
|
||||
const badges = [];
|
||||
if (log.delivered_at) {
|
||||
badges.push(
|
||||
<span key="delivered" className="rounded-full bg-emerald-100 text-emerald-700 px-2 py-0.5 text-xs font-medium">
|
||||
✓ Delivered
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (log.opened_at) {
|
||||
badges.push(
|
||||
<span key="opened" className="rounded-full bg-violet-100 text-violet-700 px-2 py-0.5 text-xs font-medium">
|
||||
Opened {formatDate(new Date(log.opened_at))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (log.clicked_at) {
|
||||
badges.push(
|
||||
<span key="clicked" className="rounded-full bg-amber-100 text-amber-700 px-2 py-0.5 text-xs font-medium">
|
||||
Clicked {formatDate(new Date(log.clicked_at))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (log.bounced_at) {
|
||||
badges.push(
|
||||
<span key="bounced" className="rounded-full bg-red-900/40 text-red-400 px-2 py-0.5 text-xs font-medium">
|
||||
✗ Bounced
|
||||
{log.bounce_reason && <span className="ml-1">{log.bounce_reason}</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return badges.length > 0 ? <div className="flex flex-wrap gap-1 mt-1">{badges}</div> : null;
|
||||
}
|
||||
|
||||
export default function MessageLogPanel({ initialLogs }: { initialLogs: MessageLogEntry[] }) {
|
||||
const [logs] = useState(initialLogs);
|
||||
const [filterStatus, setFilterStatus] = useState("all");
|
||||
const [filterMethod, setFilterMethod] = useState("all");
|
||||
|
||||
const filtered = logs.filter((l) => {
|
||||
if (filterStatus !== "all" && l.status !== filterStatus) return false;
|
||||
if (filterMethod !== "all" && l.delivery_method !== filterMethod) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-zinc-100">Message Logs</h2>
|
||||
<p className="text-sm text-zinc-500">{filtered.length} message{filtered.length !== 1 ? "s" : ""}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 mb-4 flex-wrap">
|
||||
<select
|
||||
className="text-sm border border-zinc-600 rounded-lg px-3 py-2"
|
||||
value={filterStatus}
|
||||
onChange={(e) => setFilterStatus(e.target.value)}
|
||||
>
|
||||
<option value="all">All Statuses</option>
|
||||
<option value="queued">Queued</option>
|
||||
<option value="sent">Sent</option>
|
||||
<option value="delivered">Delivered</option>
|
||||
<option value="opened">Opened</option>
|
||||
<option value="clicked">Clicked</option>
|
||||
<option value="bounced">Bounced</option>
|
||||
<option value="failed">Failed</option>
|
||||
</select>
|
||||
<select
|
||||
className="text-sm border border-zinc-600 rounded-lg px-3 py-2"
|
||||
value={filterMethod}
|
||||
onChange={(e) => setFilterMethod(e.target.value)}
|
||||
>
|
||||
<option value="all">All Methods</option>
|
||||
<option value="email">Email</option>
|
||||
<option value="sms">SMS</option>
|
||||
<option value="push">Push</option>
|
||||
<option value="internal">Internal</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<div className="text-center py-12 text-slate-400">No messages logged yet</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Desktop table */}
|
||||
<div className="hidden sm:block bg-zinc-900 rounded-xl border border-zinc-800 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-50 border-b border-zinc-800">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3 font-medium text-zinc-400">Sent At</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-zinc-400">Recipient</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-zinc-400">Method</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-zinc-400">Subject</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-zinc-400">Status</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-zinc-400">Engagement</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{filtered.map((l) => (
|
||||
<tr key={l.id} className="hover:bg-zinc-800">
|
||||
<td className="px-4 py-3 text-zinc-500 text-xs whitespace-nowrap">
|
||||
{l.sent_at ? new Date(l.sent_at).toLocaleString() : "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-zinc-300 text-xs">{l.customer_email ?? "—"}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${METHOD_COLORS[l.delivery_method] ?? "bg-zinc-950"}`}>
|
||||
{l.delivery_method}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-zinc-300 text-xs truncate max-w-[180px]">{l.subject ?? "—"}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${STATUS_COLORS[l.status] ?? "bg-zinc-950"}`}>
|
||||
{l.status}
|
||||
</span>
|
||||
{l.error_message && (
|
||||
<p className="text-xs text-red-500 mt-0.5 truncate max-w-[120px]">{l.error_message}</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
{l.delivered_at && (
|
||||
<span className="text-xs text-emerald-600">✓ {formatDate(new Date(l.delivered_at))}</span>
|
||||
)}
|
||||
{l.opened_at && (
|
||||
<span className="text-xs text-violet-600">Opened {formatDate(new Date(l.opened_at))}</span>
|
||||
)}
|
||||
{l.clicked_at && (
|
||||
<span className="text-xs text-amber-400">Clicked {formatDate(new Date(l.clicked_at))}</span>
|
||||
)}
|
||||
{l.bounced_at && (
|
||||
<span className="text-xs text-red-500">✗ Bounced{l.bounce_reason ? `: ${l.bounce_reason}` : ""}</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Mobile cards */}
|
||||
<div className="sm:hidden space-y-3">
|
||||
{filtered.map((l) => (
|
||||
<div key={l.id} className="bg-zinc-900 rounded-xl border border-zinc-800 p-4 space-y-2">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="text-sm font-medium text-zinc-100">{l.customer_email ?? "—"}</div>
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${STATUS_COLORS[l.status] ?? "bg-zinc-950"}`}>
|
||||
{l.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-zinc-500">{l.subject ?? "—"}</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${METHOD_COLORS[l.delivery_method] ?? "bg-zinc-950"}`}>
|
||||
{l.delivery_method}
|
||||
</span>
|
||||
<span className="text-xs text-slate-400">
|
||||
{l.sent_at ? formatDate(new Date(l.sent_at)) : "—"}
|
||||
</span>
|
||||
</div>
|
||||
<EngagementBadges log={l} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
"use client";
|
||||
|
||||
import NextImage from "next/image";
|
||||
import { useState, useRef } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { uploadProductImage } from "@/actions/products/upload-image";
|
||||
import { createProduct } from "@/actions/products/create-product";
|
||||
|
||||
export default function NewProductForm() {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [pendingImageUrl, setPendingImageUrl] = useState<string>("");
|
||||
|
||||
async function handleFileSelect(file: File) {
|
||||
const validTypes = ["image/png", "image/jpeg", "image/webp"];
|
||||
if (!validTypes.includes(file.type)) {
|
||||
setUploadError("Only PNG, JPEG, and WebP images are allowed.");
|
||||
return;
|
||||
}
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
setUploadError("Image must be under 5MB.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Client-side resize to max 1200px width
|
||||
const resizedBuffer = await resizeImage(file, 1200);
|
||||
const resizedFile = new File([resizedBuffer], file.name, { type: "image/jpeg" });
|
||||
|
||||
setUploadError(null);
|
||||
setUploading(true);
|
||||
const result = await uploadProductImage("__NEW__", resizedFile);
|
||||
setUploading(false);
|
||||
|
||||
if (result.success) {
|
||||
setPendingImageUrl(result.imageUrl);
|
||||
setImagePreview(result.imageUrl);
|
||||
} else {
|
||||
setUploadError(result.error ?? "Upload failed.");
|
||||
}
|
||||
}
|
||||
|
||||
async function resizeImage(file: File, maxWidth: number): Promise<ArrayBuffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
let { width, height } = img;
|
||||
if (width > maxWidth) {
|
||||
height = Math.round(height * (maxWidth / width));
|
||||
width = maxWidth;
|
||||
}
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
ctx.drawImage(img, 0, 0, width, height);
|
||||
canvas.toBlob((blob) => {
|
||||
if (!blob) {
|
||||
reject(new Error("Failed to resize image"));
|
||||
return;
|
||||
}
|
||||
blob.arrayBuffer().then((buf) => resolve(buf));
|
||||
}, "image/jpeg", 0.85);
|
||||
};
|
||||
img.onerror = reject;
|
||||
img.src = URL.createObjectURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const formData = new FormData(e.currentTarget);
|
||||
const name = formData.get("name") as string;
|
||||
const description = formData.get("description") as string;
|
||||
const price = parseFloat(formData.get("price") as string);
|
||||
const type = formData.get("type") as string;
|
||||
const brandId = formData.get("brand_id") as string;
|
||||
const active = formData.get("active") === "true";
|
||||
|
||||
// Use pendingImageUrl if image was uploaded, otherwise null
|
||||
const imageUrl = pendingImageUrl || null;
|
||||
const isTaxable = formData.get("is_taxable") === "true";
|
||||
const pickup_type = formData.get("pickup_type") as string;
|
||||
|
||||
const result = await createProduct(brandId, {
|
||||
name,
|
||||
description,
|
||||
price,
|
||||
type,
|
||||
active,
|
||||
image_url: imageUrl,
|
||||
is_taxable: isTaxable,
|
||||
pickup_type,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
setError(result.error ?? "Failed to create product");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
router.push("/admin/products");
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="mt-8 max-w-xl space-y-6">
|
||||
{error && (
|
||||
<div className="rounded-xl bg-red-900/30 p-4 text-red-400 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300">
|
||||
Product Name
|
||||
</label>
|
||||
<input
|
||||
name="name"
|
||||
required
|
||||
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-zinc-100 outline-none focus:border-slate-900"
|
||||
placeholder="e.g. Dozen Sweet Corn"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
name="description"
|
||||
rows={3}
|
||||
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-zinc-100 outline-none focus:border-slate-900"
|
||||
placeholder="e.g. Fresh-picked Olathe sweet corn."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300">
|
||||
Price ($)
|
||||
</label>
|
||||
<input
|
||||
name="price"
|
||||
type="number"
|
||||
step="0.01"
|
||||
required
|
||||
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-zinc-100 outline-none focus:border-slate-900"
|
||||
placeholder="12.00"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300">
|
||||
Type
|
||||
</label>
|
||||
<select
|
||||
name="type"
|
||||
required
|
||||
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-zinc-100 outline-none focus:border-slate-900"
|
||||
>
|
||||
<option value="Pickup">Pickup</option>
|
||||
<option value="Shipping">Shipping</option>
|
||||
<option value="Pickup & Shipping">Pickup & Shipping</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300">
|
||||
Brand
|
||||
</label>
|
||||
<select
|
||||
name="brand_id"
|
||||
required
|
||||
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-zinc-100 outline-none focus:border-slate-900"
|
||||
>
|
||||
<option value="">Select brand...</option>
|
||||
<option value="64294306-5f42-463d-a5e8-2ad6c81a96de">
|
||||
Tuxedo Corn
|
||||
</option>
|
||||
<option value="b1cb7a96-d82b-40b1-80b1-d6dd26c56e28">
|
||||
Indian River Direct
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300">Taxable</label>
|
||||
<p className="text-xs text-zinc-500 mb-2">Tax applied at checkout for shipping orders in nexus states. Disable for non-taxable items like apparel or cooler boxes.</p>
|
||||
<select
|
||||
name="is_taxable"
|
||||
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-zinc-100 outline-none focus:border-slate-900"
|
||||
>
|
||||
<option value="true">Yes — taxable (default)</option>
|
||||
<option value="false">No — non-taxable</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300">Pickup Type</label>
|
||||
<p className="text-xs text-zinc-500 mb-2">Shed pickup uses the product description as the location — no stop required at checkout.</p>
|
||||
<select
|
||||
name="pickup_type"
|
||||
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-zinc-100 outline-none focus:border-slate-900"
|
||||
>
|
||||
<option value="scheduled_stop">Scheduled Stop — requires stop selection at checkout</option>
|
||||
<option value="shed">Shed Pickup — uses description as location</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300">
|
||||
Active
|
||||
</label>
|
||||
<select
|
||||
name="active"
|
||||
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-zinc-100 outline-none focus:border-slate-900"
|
||||
>
|
||||
<option value="true">Yes — show on storefront</option>
|
||||
<option value="false">No — hide from storefront</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300">Product Image</label>
|
||||
<p className="text-xs text-zinc-500 mb-2">JPG, PNG, WebP · 1200px max width · max 5MB (ideally under 2MB)</p>
|
||||
|
||||
<div
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file) handleFileSelect(file);
|
||||
}}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className={`
|
||||
flex flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed p-6 cursor-pointer transition-colors
|
||||
${uploadError ? "border-red-400" : "border-zinc-600 hover:border-slate-400 hover:bg-zinc-900"}
|
||||
${uploading ? "opacity-50 pointer-events-none" : ""}
|
||||
`}
|
||||
>
|
||||
{uploading ? (
|
||||
<>
|
||||
<div className="h-5 w-5 rounded-full border-2 border-slate-400 border-t-transparent animate-spin" />
|
||||
<span className="text-sm text-zinc-500">Uploading...</span>
|
||||
</>
|
||||
) : imagePreview ? (
|
||||
<>
|
||||
<span className="relative inline-block h-32 w-auto">
|
||||
<NextImage src={imagePreview} alt="Product preview" fill style={{ objectFit: "contain" }} className="rounded-lg" />
|
||||
</span>
|
||||
<span className="text-xs text-zinc-500">Click or drop to replace</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="h-8 w-8 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<span className="text-sm text-zinc-500">Drag & drop or click to upload</span>
|
||||
</>
|
||||
)}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) handleFileSelect(file);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{uploadError && <p className="mt-1 text-xs text-red-400">{uploadError}</p>}
|
||||
|
||||
{imagePreview && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setImagePreview(null); setPendingImageUrl(""); }}
|
||||
className="mt-2 text-xs text-red-500 hover:underline"
|
||||
>
|
||||
Remove image
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="rounded-xl bg-slate-900 px-6 py-3 font-medium text-white disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Creating..." : "Create Product"}
|
||||
</button>
|
||||
|
||||
<a
|
||||
href="/admin/products"
|
||||
className="rounded-xl border border-zinc-600 px-6 py-3 font-medium text-zinc-300"
|
||||
>
|
||||
Cancel
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { createStop } from "@/actions/stops/create-stop";
|
||||
|
||||
type Stop = {
|
||||
city: string;
|
||||
state: string;
|
||||
location: string;
|
||||
date: string;
|
||||
time: string;
|
||||
brand_id: string;
|
||||
active: boolean;
|
||||
address?: string | null;
|
||||
zip?: string | null;
|
||||
cutoff_time?: string | null;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
duplicateFrom?: Stop | null;
|
||||
};
|
||||
|
||||
export default function NewStopForm({ duplicateFrom }: Props) {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const defaultBrand = duplicateFrom?.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const formData = new FormData(e.currentTarget);
|
||||
const city = formData.get("city") as string;
|
||||
const state = formData.get("state") as string;
|
||||
const location = formData.get("location") as string;
|
||||
const date = formData.get("date") as string;
|
||||
const time = formData.get("time") as string;
|
||||
const brandId = formData.get("brand_id") as string;
|
||||
const active = formData.get("active") === "true";
|
||||
|
||||
const result = await createStop(brandId, {
|
||||
city,
|
||||
state,
|
||||
location,
|
||||
date,
|
||||
time,
|
||||
active,
|
||||
address: (formData.get("address") as string) || null,
|
||||
zip: (formData.get("zip") as string) || null,
|
||||
cutoff_time: (formData.get("cutoff_time") as string) || null,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
setError(result.error ?? "Failed to create stop");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.id) {
|
||||
router.push(`/admin/stops/${result.id}`);
|
||||
} else {
|
||||
router.push("/admin/stops");
|
||||
}
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="mt-8 max-w-xl space-y-6">
|
||||
{error && (
|
||||
<div className="rounded-xl bg-red-900/30 p-4 text-red-400 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300">City</label>
|
||||
<input
|
||||
name="city"
|
||||
required
|
||||
defaultValue={duplicateFrom?.city}
|
||||
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-zinc-100 outline-none focus:border-slate-900"
|
||||
placeholder="e.g. Denver"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300">State</label>
|
||||
<input
|
||||
name="state"
|
||||
required
|
||||
maxLength={2}
|
||||
defaultValue={duplicateFrom?.state}
|
||||
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-zinc-100 outline-none focus:border-slate-900"
|
||||
placeholder="e.g. CO"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300">Location Name</label>
|
||||
<input
|
||||
name="location"
|
||||
required
|
||||
defaultValue={duplicateFrom?.location}
|
||||
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-zinc-100 outline-none focus:border-slate-900"
|
||||
placeholder="e.g. Southwest Plaza Parking Lot"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300">Date</label>
|
||||
<input
|
||||
name="date"
|
||||
required
|
||||
type="date"
|
||||
defaultValue={duplicateFrom?.date}
|
||||
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-zinc-100 outline-none focus:border-slate-900"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300">Time</label>
|
||||
<input
|
||||
name="time"
|
||||
required
|
||||
type="text"
|
||||
defaultValue={duplicateFrom?.time}
|
||||
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-zinc-100 outline-none focus:border-slate-900"
|
||||
placeholder="e.g. 8:00 AM – 2:00 PM"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300">Brand</label>
|
||||
<select
|
||||
name="brand_id"
|
||||
required
|
||||
defaultValue={defaultBrand}
|
||||
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-zinc-100 outline-none focus:border-slate-900"
|
||||
>
|
||||
<option value="">Select brand...</option>
|
||||
<option value="64294306-5f42-463d-a5e8-2ad6c81a96de">Tuxedo Corn</option>
|
||||
<option value="b1cb7a96-d82b-40b1-80b1-d6dd26c56e28">Indian River Direct</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300">Active</label>
|
||||
<select
|
||||
name="active"
|
||||
defaultValue="true"
|
||||
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-zinc-100 outline-none focus:border-slate-900"
|
||||
>
|
||||
<option value="true">Yes — show on storefront</option>
|
||||
<option value="false">No — hide from storefront</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300">Street Address</label>
|
||||
<input
|
||||
name="address"
|
||||
defaultValue={duplicateFrom?.address ?? ""}
|
||||
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-zinc-100 outline-none focus:border-slate-900"
|
||||
placeholder="123 Main St"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300">ZIP Code</label>
|
||||
<input
|
||||
name="zip"
|
||||
maxLength={10}
|
||||
defaultValue={duplicateFrom?.zip ?? ""}
|
||||
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-zinc-100 outline-none focus:border-slate-900"
|
||||
placeholder="80102"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300">Order Cutoff</label>
|
||||
<input
|
||||
name="cutoff_time"
|
||||
type="datetime-local"
|
||||
defaultValue={duplicateFrom?.cutoff_time ?? ""}
|
||||
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-zinc-100 outline-none focus:border-slate-900"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-slate-400">
|
||||
Customers must order before this time to be included at this stop.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="rounded-xl bg-slate-900 px-6 py-3 font-medium text-white disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Creating..." : "Create Stop"}
|
||||
</button>
|
||||
|
||||
<a
|
||||
href="/admin/stops"
|
||||
className="rounded-xl border border-zinc-600 px-6 py-3 font-medium text-zinc-300"
|
||||
>
|
||||
Cancel
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { updateOrder, updateOrderItem, deleteOrderItem } from "@/actions/orders/update-order";
|
||||
|
||||
type OrderItem = {
|
||||
id: string;
|
||||
product_id: string;
|
||||
quantity: number;
|
||||
price: number;
|
||||
products: { name: string } | null;
|
||||
};
|
||||
|
||||
type Order = {
|
||||
id: string;
|
||||
customer_name: string;
|
||||
customer_email: string | null;
|
||||
customer_phone: string | null;
|
||||
stop_id: string | null;
|
||||
status: string;
|
||||
subtotal: number;
|
||||
discount_amount: number;
|
||||
tax_amount: number | null;
|
||||
tax_rate: number | null;
|
||||
tax_source: string | null;
|
||||
tax_location: string | null;
|
||||
internal_notes: string | null;
|
||||
discount_reason: string | null;
|
||||
pickup_complete: boolean;
|
||||
pickup_completed_at: string | null;
|
||||
pickup_completed_by: string | null;
|
||||
created_at: string;
|
||||
stops: { city: string; state: string; date: string } | null;
|
||||
order_items: OrderItem[];
|
||||
};
|
||||
|
||||
type OrderEditFormProps = {
|
||||
order: Order;
|
||||
brandId: string | null;
|
||||
};
|
||||
|
||||
type EditableItem = {
|
||||
id: string;
|
||||
product_id: string;
|
||||
quantity: number;
|
||||
price: number;
|
||||
productName: string;
|
||||
removed: boolean;
|
||||
};
|
||||
|
||||
export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
|
||||
const router = useRouter();
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
const [customer_name, setCustomer_name] = useState(order.customer_name);
|
||||
const [customer_email, setCustomer_email] = useState(order.customer_email ?? "");
|
||||
const [customer_phone, setCustomer_phone] = useState(order.customer_phone ?? "");
|
||||
const [discount_amount, setDiscount_amount] = useState(order.discount_amount ?? 0);
|
||||
const [discount_reason, setDiscount_reason] = useState(order.discount_reason ?? "");
|
||||
const [internal_notes, setInternal_notes] = useState(order.internal_notes ?? "");
|
||||
const [status, setStatus] = useState(order.status);
|
||||
const [pickup_complete, setPickup_complete] = useState(order.pickup_complete);
|
||||
|
||||
const [items, setItems] = useState<EditableItem[]>(
|
||||
order.order_items.map((item) => ({
|
||||
id: item.id,
|
||||
product_id: item.product_id,
|
||||
quantity: item.quantity,
|
||||
price: Number(item.price),
|
||||
productName: item.products?.name ?? "Unknown",
|
||||
removed: false,
|
||||
}))
|
||||
);
|
||||
|
||||
const visibleItems = items.filter((i) => !i.removed);
|
||||
const subtotal = visibleItems.reduce(
|
||||
(sum, i) => sum + Number(i.price) * i.quantity,
|
||||
0
|
||||
);
|
||||
const total = Math.max(0, subtotal - discount_amount);
|
||||
|
||||
function updateItem(id: string, field: "quantity" | "price", value: number) {
|
||||
setItems((prev) =>
|
||||
prev.map((i) => (i.id === id ? { ...i, [field]: value } : i))
|
||||
);
|
||||
}
|
||||
|
||||
function removeItem(id: string) {
|
||||
setItems((prev) =>
|
||||
prev.map((i) => (i.id === id ? { ...i, removed: true } : i))
|
||||
);
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
setSaved(false);
|
||||
|
||||
const toSave = items.filter((i) => !i.removed);
|
||||
const toRemove = items.filter((i) => i.removed);
|
||||
|
||||
for (const item of toRemove) {
|
||||
const result = await deleteOrderItem(item.id);
|
||||
if (!result.success) {
|
||||
setError(result.error ?? "Failed to remove item");
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of toSave) {
|
||||
const orig = order.order_items.find((o) => o.id === item.id);
|
||||
if (!orig) continue;
|
||||
if (
|
||||
Number(orig.quantity) !== item.quantity ||
|
||||
Number(orig.price) !== item.price
|
||||
) {
|
||||
const result = await updateOrderItem(item.id, {
|
||||
quantity: item.quantity,
|
||||
price: Number(item.price),
|
||||
});
|
||||
if (!result.success) {
|
||||
setError(result.error ?? "Failed to update item");
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result = await updateOrder(order.id, brandId, {
|
||||
customer_name,
|
||||
customer_email: customer_email || null,
|
||||
customer_phone: customer_phone || null,
|
||||
discount_amount,
|
||||
discount_reason: discount_reason || null,
|
||||
internal_notes: internal_notes || null,
|
||||
status,
|
||||
pickup_complete,
|
||||
pickup_completed_at: pickup_complete ? new Date().toISOString() : null,
|
||||
subtotal,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
setError(result.error ?? "Failed to save");
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setSaved(true);
|
||||
setSaving(false);
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{error && (
|
||||
<div className="rounded-xl bg-red-900/30 p-4 text-sm text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{saved && (
|
||||
<div className="rounded-xl bg-green-900/30 p-4 text-sm text-green-400">
|
||||
Order updated successfully.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Order items */}
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-medium text-zinc-300">
|
||||
Order Items ({visibleItems.length})
|
||||
</p>
|
||||
{visibleItems.length === 0 ? (
|
||||
<p className="rounded-xl border border-dashed border-zinc-600 p-4 text-center text-sm text-zinc-500">
|
||||
No items.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{visibleItems.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="rounded-xl border border-zinc-800 bg-zinc-900 p-4"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<p className="font-medium text-zinc-100">{item.productName}</p>
|
||||
<button
|
||||
onClick={() => removeItem(item.id)}
|
||||
className="shrink-0 rounded-lg px-2 py-1 text-xs font-medium text-red-400 hover:bg-red-900/30"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-zinc-500">
|
||||
Qty
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={item.quantity}
|
||||
onChange={(e) =>
|
||||
updateItem(item.id, "quantity", Number(e.target.value))
|
||||
}
|
||||
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-3 py-2 text-sm outline-none focus:border-slate-900"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-zinc-500">
|
||||
Price
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={item.price}
|
||||
onChange={(e) =>
|
||||
updateItem(item.id, "price", Number(e.target.value))
|
||||
}
|
||||
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-3 py-2 text-sm outline-none focus:border-slate-900"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mt-2 text-right text-sm font-semibold text-zinc-100">
|
||||
${(Number(item.price) * item.quantity).toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pricing — subtotal derived from items */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-zinc-300">
|
||||
Subtotal (auto-calculated)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={subtotal}
|
||||
readOnly
|
||||
className="w-full rounded-xl border border-zinc-800 bg-zinc-900 px-4 py-3 text-base text-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-zinc-300">
|
||||
Discount Amount
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={discount_amount}
|
||||
onChange={(e) => setDiscount_amount(Number(e.target.value))}
|
||||
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base outline-none focus:border-slate-900"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-zinc-300">
|
||||
Discount Reason
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={discount_reason}
|
||||
onChange={(e) => setDiscount_reason(e.target.value)}
|
||||
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base outline-none focus:border-slate-900"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-zinc-300">
|
||||
Tax Amount
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={order.tax_amount ?? 0}
|
||||
onChange={(e) => {}}
|
||||
readOnly
|
||||
className="w-full rounded-xl border border-zinc-800 bg-zinc-900 px-4 py-3 text-base text-zinc-500"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-slate-400 italic">Taxes calculated at payment integration.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-zinc-300">
|
||||
Tax Rate
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={order.tax_rate ?? ""}
|
||||
readOnly
|
||||
placeholder="e.g. 0.08"
|
||||
className="w-full rounded-xl border border-zinc-800 bg-zinc-900 px-4 py-3 text-base text-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-zinc-300">
|
||||
Tax Location
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={order.tax_location ?? ""}
|
||||
readOnly
|
||||
placeholder="e.g. NC"
|
||||
className="w-full rounded-xl border border-zinc-800 bg-zinc-900 px-4 py-3 text-base text-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-zinc-800 bg-zinc-900 p-4">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-lg font-medium text-zinc-300">Total</span>
|
||||
<span className="text-2xl font-bold text-zinc-100">
|
||||
${(subtotal + Number(order.tax_amount ?? 0) - discount_amount).toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Customer fields */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-zinc-300">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={customer_name}
|
||||
onChange={(e) => setCustomer_name(e.target.value)}
|
||||
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base outline-none focus:border-slate-900"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-zinc-300">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={customer_email}
|
||||
onChange={(e) => setCustomer_email(e.target.value)}
|
||||
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base outline-none focus:border-slate-900"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-zinc-300">
|
||||
Phone
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={customer_phone}
|
||||
onChange={(e) => setCustomer_phone(e.target.value)}
|
||||
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base outline-none focus:border-slate-900"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status & pickup */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-zinc-300">
|
||||
Status
|
||||
</label>
|
||||
<div className="flex gap-3">
|
||||
{["pending", "confirmed", "cancelled"].map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setStatus(s)}
|
||||
className={`flex-1 rounded-xl px-4 py-3 text-sm font-medium capitalize transition-colors ${
|
||||
status === s
|
||||
? "bg-slate-900 text-white"
|
||||
: "bg-zinc-950 text-zinc-400 hover:bg-slate-200"
|
||||
}`}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-zinc-300">
|
||||
Pickup
|
||||
</label>
|
||||
<button
|
||||
onClick={() => setPickup_complete((v) => !v)}
|
||||
className={`w-full rounded-xl px-4 py-3 text-sm font-medium transition-colors ${
|
||||
pickup_complete
|
||||
? "bg-green-900/40 text-green-400"
|
||||
: "bg-zinc-950 text-zinc-400 hover:bg-slate-200"
|
||||
}`}
|
||||
>
|
||||
{pickup_complete ? "Picked Up" : "Not Picked Up"}
|
||||
</button>
|
||||
{order.pickup_complete && order.pickup_completed_at && (
|
||||
<p className="mt-1 text-xs text-slate-400">
|
||||
Completed{" "}
|
||||
{new Date(order.pickup_completed_at).toLocaleString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Internal notes */}
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-zinc-300">
|
||||
Internal Notes
|
||||
</label>
|
||||
<textarea
|
||||
value={internal_notes}
|
||||
onChange={(e) => setInternal_notes(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base outline-none focus:border-slate-900"
|
||||
placeholder="Private notes for staff..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="w-full rounded-xl bg-slate-900 px-6 py-4 text-lg font-bold text-white disabled:opacity-50"
|
||||
>
|
||||
{saving ? "Saving..." : "Save Changes"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { updateOrder } from "@/actions/orders/update-order";
|
||||
import { createRefund } from "@/actions/orders/create-refund";
|
||||
|
||||
type Refund = {
|
||||
id: string;
|
||||
order_id: string;
|
||||
amount: number;
|
||||
reason: string | null;
|
||||
processor: string | null;
|
||||
processor_refund_id: string | null;
|
||||
status: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
type OrderPaymentSectionProps = {
|
||||
orderId: string;
|
||||
brandId: string | null;
|
||||
orderTotal: number;
|
||||
payment_processor: string | null;
|
||||
payment_status: string | null;
|
||||
payment_transaction_id: string | null;
|
||||
refunded_amount: number;
|
||||
refund_reason: string | null;
|
||||
existingRefunds: Refund[];
|
||||
};
|
||||
|
||||
export default function OrderPaymentSection({
|
||||
orderId,
|
||||
brandId,
|
||||
orderTotal,
|
||||
payment_processor,
|
||||
payment_status,
|
||||
payment_transaction_id,
|
||||
refunded_amount,
|
||||
refund_reason,
|
||||
existingRefunds,
|
||||
}: OrderPaymentSectionProps) {
|
||||
const router = useRouter();
|
||||
const [processor, setProcessor] = useState(payment_processor ?? "");
|
||||
const [status, setStatus] = useState(payment_status ?? "manual");
|
||||
const [transactionId, setTransactionId] = useState(payment_transaction_id ?? "");
|
||||
const [refundAmount, setRefundAmount] = useState("");
|
||||
const [refundReason, setRefundReason] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [refunding, setRefunding] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [refundSaved, setRefundSaved] = useState(false);
|
||||
|
||||
async function handleSavePayment(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
setSaved(false);
|
||||
|
||||
const result = await updateOrder(orderId, brandId, {
|
||||
payment_processor: processor || null,
|
||||
payment_status: status,
|
||||
payment_transaction_id: transactionId || null,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
setError(result.error ?? "Failed to save");
|
||||
} else {
|
||||
setSaved(true);
|
||||
}
|
||||
setSaving(false);
|
||||
}
|
||||
|
||||
async function handleRefund(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const amount = Number(refundAmount);
|
||||
if (!amount || amount <= 0) return;
|
||||
|
||||
setRefunding(true);
|
||||
setError(null);
|
||||
setRefundSaved(false);
|
||||
|
||||
const result = await createRefund(orderId, brandId, {
|
||||
amount,
|
||||
reason: refundReason || null,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
setError(result.error ?? "Failed to record refund");
|
||||
} else {
|
||||
setRefundAmount("");
|
||||
setRefundReason("");
|
||||
setRefundSaved(true);
|
||||
}
|
||||
setRefunding(false);
|
||||
}
|
||||
|
||||
const totalRefunded = existingRefunds
|
||||
.filter((r) => r.status === "completed")
|
||||
.reduce((sum, r) => sum + Number(r.amount), 0);
|
||||
const remainingBalance = Math.max(0, orderTotal - totalRefunded);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{error && (
|
||||
<div className="rounded-xl bg-red-900/30 p-4 text-sm text-red-400">{error}</div>
|
||||
)}
|
||||
|
||||
{/* Payment details */}
|
||||
<form onSubmit={handleSavePayment} className="space-y-4">
|
||||
{saved && (
|
||||
<div className="rounded-xl bg-green-900/30 p-4 text-sm text-green-400">
|
||||
Payment details saved.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-zinc-300">
|
||||
Processor
|
||||
</label>
|
||||
<select
|
||||
value={processor}
|
||||
onChange={(e) => setProcessor(e.target.value)}
|
||||
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base outline-none focus:border-slate-900"
|
||||
>
|
||||
<option value="">—</option>
|
||||
{["manual", "stripe", "square", "cash", "venmo", "other"].map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{p.charAt(0).toUpperCase() + p.slice(1)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-zinc-300">
|
||||
Payment Status
|
||||
</label>
|
||||
<select
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value)}
|
||||
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base outline-none focus:border-slate-900"
|
||||
>
|
||||
{["pending", "paid", "failed", "refunded", "cancelled"].map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s.charAt(0).toUpperCase() + s.slice(1)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-zinc-300">
|
||||
Transaction ID
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={transactionId}
|
||||
onChange={(e) => setTransactionId(e.target.value)}
|
||||
placeholder="External payment reference"
|
||||
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base outline-none focus:border-slate-900"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="w-full rounded-xl bg-slate-900 px-5 py-3 text-sm font-medium text-white disabled:opacity-50"
|
||||
>
|
||||
{saving ? "Saving..." : "Save Payment Details"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Refund history */}
|
||||
{existingRefunds.length > 0 && (
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-medium text-zinc-300">
|
||||
Refunds ({existingRefunds.length})
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{existingRefunds.map((r) => (
|
||||
<div
|
||||
key={r.id}
|
||||
className="flex items-center justify-between rounded-xl border border-zinc-800 bg-slate-50 px-4 py-3"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-zinc-100">
|
||||
${Number(r.amount).toFixed(2)}
|
||||
</p>
|
||||
{r.reason && (
|
||||
<p className="text-xs text-zinc-500">{r.reason}</p>
|
||||
)}
|
||||
<p className="text-xs text-slate-400">
|
||||
{formatDate(new Date(r.created_at))}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-xs font-medium ${
|
||||
r.status === "completed"
|
||||
? "bg-green-900/40 text-green-400"
|
||||
: "bg-zinc-950 text-zinc-500"
|
||||
}`}
|
||||
>
|
||||
{r.status}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-2 flex justify-between rounded-xl border border-zinc-800 bg-zinc-900 px-4 py-2 text-sm">
|
||||
<span className="text-zinc-500">Total refunded</span>
|
||||
<span className="font-medium text-zinc-100">
|
||||
${totalRefunded.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Record a refund */}
|
||||
<div className="rounded-xl border border-dashed border-zinc-600 p-4">
|
||||
<p className="mb-3 text-sm font-medium text-zinc-300">Record a Refund</p>
|
||||
{refundSaved && (
|
||||
<div className="mb-3 rounded-xl bg-green-900/30 p-3 text-sm text-green-400">
|
||||
Refund recorded.
|
||||
</div>
|
||||
)}
|
||||
<form onSubmit={handleRefund} className="space-y-3">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-zinc-500">
|
||||
Amount
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0.01"
|
||||
max={remainingBalance}
|
||||
value={refundAmount}
|
||||
onChange={(e) => setRefundAmount(e.target.value)}
|
||||
placeholder={`Max $${remainingBalance.toFixed(2)}`}
|
||||
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-2 text-sm outline-none focus:border-slate-900"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-zinc-500">
|
||||
Reason
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={refundReason}
|
||||
onChange={(e) => setRefundReason(e.target.value)}
|
||||
placeholder="Customer request, defective product, etc."
|
||||
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-2 text-sm outline-none focus:border-slate-900"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!refundAmount || Number(refundAmount) <= 0 || refunding}
|
||||
className="w-full rounded-xl border border-red-300 px-4 py-2 text-sm font-medium text-red-400 hover:bg-red-900/30 disabled:opacity-50"
|
||||
>
|
||||
{refunding ? "Processing..." : "Record Refund"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { markPickupComplete } from "@/actions/pickup";
|
||||
|
||||
type Props = {
|
||||
orderId: string;
|
||||
brandId: string | null;
|
||||
currentlyPickedUp: boolean;
|
||||
};
|
||||
|
||||
export default function OrderPickupAction({
|
||||
orderId,
|
||||
brandId,
|
||||
currentlyPickedUp,
|
||||
}: Props) {
|
||||
const [pickingUp, setPickingUp] = useState(false);
|
||||
const [toast, setToast] = useState<{ type: "success" | "error"; msg: string } | null>(null);
|
||||
|
||||
async function handlePickup() {
|
||||
setPickingUp(true);
|
||||
const result = await markPickupComplete(orderId, brandId);
|
||||
setPickingUp(false);
|
||||
if (result.success) {
|
||||
setToast({ type: "success", msg: "Order marked as picked up." });
|
||||
setTimeout(() => window.location.reload(), 1200);
|
||||
} else {
|
||||
setToast({ type: "error", msg: result.error ?? "Failed to mark picked up." });
|
||||
}
|
||||
}
|
||||
|
||||
if (currentlyPickedUp) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-green-100 px-3 py-1 text-sm font-semibold text-green-700">
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Picked Up
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<button
|
||||
onClick={handlePickup}
|
||||
disabled={pickingUp}
|
||||
className="rounded-xl bg-green-600 px-5 py-2.5 text-sm font-semibold text-white hover:bg-green-700 disabled:opacity-50"
|
||||
>
|
||||
{pickingUp ? "Marking..." : "Mark Picked Up"}
|
||||
</button>
|
||||
{toast && (
|
||||
<div
|
||||
className={`rounded-lg px-3 py-2 text-sm font-medium ${
|
||||
toast.type === "success" ? "bg-green-50 text-green-700" : "bg-red-50 text-red-700"
|
||||
}`}
|
||||
>
|
||||
{toast.msg}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
|
||||
type Order = {
|
||||
id: string;
|
||||
customer_name: string;
|
||||
customer_email: string | null;
|
||||
stop_id: string;
|
||||
status: string;
|
||||
subtotal: number;
|
||||
pickup_complete: boolean;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export default function OrderTableBody({ orders }: { orders: Order[] }) {
|
||||
const [pickupToggles, setPickupToggles] = useState<Record<string, boolean>>(
|
||||
() => Object.fromEntries(orders.map((o) => [o.id, o.pickup_complete]))
|
||||
);
|
||||
|
||||
async function togglePickup(orderId: string, current: boolean) {
|
||||
setPickupToggles((prev) => ({ ...prev, [orderId]: !current }));
|
||||
await fetch(
|
||||
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/orders?id=eq.${orderId}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||
"Content-Type": "application/json",
|
||||
Prefer: "return=representation",
|
||||
},
|
||||
body: JSON.stringify({ pickup_complete: !current }),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
pending: "bg-yellow-100 text-yellow-700",
|
||||
confirmed: "bg-blue-900/40 text-blue-700",
|
||||
paid: "bg-green-900/40 text-green-400",
|
||||
cancelled: "bg-red-900/40 text-red-400",
|
||||
completed: "bg-zinc-950 text-zinc-400",
|
||||
};
|
||||
|
||||
return (
|
||||
<tbody className="divide-y divide-slate-200">
|
||||
{orders.map((order) => (
|
||||
<tr key={order.id} className="hover:bg-zinc-800">
|
||||
<td className="px-5 py-4">
|
||||
<span className="font-mono text-sm text-zinc-500">
|
||||
{order.id.slice(0, 8)}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<td className="px-5 py-4">
|
||||
<div className="font-medium text-zinc-100">
|
||||
{order.customer_name}
|
||||
</div>
|
||||
<div className="text-sm text-zinc-500">
|
||||
{order.customer_email}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td className="px-5 py-4">
|
||||
<span
|
||||
className={`rounded-full px-3 py-1 text-xs font-medium ${
|
||||
statusColors[order.status] ?? "bg-zinc-950 text-zinc-400"
|
||||
}`}
|
||||
>
|
||||
{order.status ?? "pending"}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<td className="px-5 py-4 font-semibold text-zinc-100">
|
||||
${Number(order.subtotal).toFixed(2)}
|
||||
</td>
|
||||
|
||||
<td className="px-5 py-4">
|
||||
<button
|
||||
onClick={() => togglePickup(order.id, pickupToggles[order.id])}
|
||||
className={`rounded-full px-3 py-1 text-xs font-medium ${
|
||||
pickupToggles[order.id]
|
||||
? "bg-green-900/40 text-green-400"
|
||||
: "bg-zinc-950 text-zinc-500"
|
||||
}`}
|
||||
>
|
||||
{pickupToggles[order.id] ? "Picked up" : "Pending"}
|
||||
</button>
|
||||
</td>
|
||||
|
||||
<td className="px-5 py-4 text-sm text-zinc-500">
|
||||
{formatDate(new Date(order.created_at))}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { savePaymentSettings, type PaymentProvider, type PaymentSettings } from "@/actions/payments";
|
||||
import { syncSquareNow, getSyncLog, type SyncLogEntry } from "@/actions/square-sync-ui";
|
||||
import WebhookLogsSection from "@/components/admin/WebhookLogsSection";
|
||||
|
||||
type InventoryMode = "none" | "rc_to_square" | "square_to_rc" | "bidirectional";
|
||||
|
||||
type Props = {
|
||||
settings: {
|
||||
provider?: PaymentProvider | null;
|
||||
stripe_publishable_key?: string | null;
|
||||
stripe_secret_key?: string | null;
|
||||
square_access_token?: string | null;
|
||||
square_location_id?: string | null;
|
||||
square_sync_enabled?: boolean;
|
||||
square_inventory_mode?: InventoryMode;
|
||||
square_last_sync_at?: string | null;
|
||||
square_last_sync_error?: string | null;
|
||||
} | null;
|
||||
brandId: string;
|
||||
brands?: { id: string; name: string }[];
|
||||
isPlatformAdmin?: boolean;
|
||||
};
|
||||
|
||||
export default function PaymentSettingsForm({ settings, brandId, brands = [], isPlatformAdmin = false }: Props) {
|
||||
const [activeBrandId, setActiveBrandId] = useState(brandId);
|
||||
const [provider, setProvider] = useState<PaymentProvider | "">(
|
||||
(settings?.provider ?? "") as PaymentProvider | ""
|
||||
);
|
||||
const [stripePublishableKey, setStripePublishableKey] = useState(
|
||||
settings?.stripe_publishable_key ?? ""
|
||||
);
|
||||
const [stripeSecretKey, setStripeSecretKey] = useState(
|
||||
settings?.stripe_secret_key ?? ""
|
||||
);
|
||||
const [squareAccessToken, setSquareAccessToken] = useState(
|
||||
settings?.square_access_token ?? ""
|
||||
);
|
||||
const [squareLocationId, setSquareLocationId] = useState(
|
||||
settings?.square_location_id ?? ""
|
||||
);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showStripe, setShowStripe] = useState(settings?.provider === "stripe" || settings?.provider === "square" ? settings.provider === "stripe" : false);
|
||||
const [showSquare, setShowSquare] = useState(settings?.provider === "square");
|
||||
|
||||
// Square Sync state
|
||||
const [squareSyncEnabled, setSquareSyncEnabled] = useState(
|
||||
settings?.square_sync_enabled ?? false
|
||||
);
|
||||
const [squareInventoryMode, setSquareInventoryMode] = useState<InventoryMode>(
|
||||
settings?.square_inventory_mode ?? "none"
|
||||
);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [syncResult, setSyncResult] = useState<{ success: boolean; message: string } | null>(null);
|
||||
const [syncLog, setSyncLog] = useState<SyncLogEntry[]>([]);
|
||||
|
||||
const hasSquareToken = !!settings?.square_access_token;
|
||||
|
||||
// Read URL params to show connection success/error
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (params.get("square_connected") === "true") {
|
||||
setSyncResult({ success: true, message: "Square connected successfully!" });
|
||||
window.history.replaceState({}, "", window.location.pathname);
|
||||
}
|
||||
const err = params.get("error");
|
||||
if (err) {
|
||||
setSyncResult({ success: false, message: `Square connection failed: ${err}` });
|
||||
window.history.replaceState({}, "", window.location.pathname);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Load sync log on mount
|
||||
useEffect(() => {
|
||||
if (hasSquareToken) {
|
||||
getSyncLog(activeBrandId).then((result) => {
|
||||
if (result.success) setSyncLog(result.logs);
|
||||
});
|
||||
}
|
||||
}, [hasSquareToken, activeBrandId]);
|
||||
|
||||
async function handleSave(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
|
||||
const result = await savePaymentSettings({
|
||||
brandId: activeBrandId,
|
||||
provider: provider || null,
|
||||
stripePublishableKey: stripePublishableKey || undefined,
|
||||
stripeSecretKey: stripeSecretKey || undefined,
|
||||
squareAccessToken: squareAccessToken || undefined,
|
||||
squareLocationId: squareLocationId || undefined,
|
||||
squareSyncEnabled,
|
||||
squareInventoryMode,
|
||||
});
|
||||
|
||||
setSaving(false);
|
||||
if (!result.success) {
|
||||
setError(result.error ?? "Failed to save");
|
||||
} else {
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 3000);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSyncNow(type: string) {
|
||||
setSyncing(true);
|
||||
setSyncResult(null);
|
||||
const result = await syncSquareNow(activeBrandId, type as "products" | "orders" | "all");
|
||||
setSyncResult({
|
||||
success: result.success,
|
||||
message: result.success
|
||||
? `Synced ${result.synced} item(s).`
|
||||
: `Sync failed: ${result.errors?.[0] ?? "Unknown error"}`,
|
||||
});
|
||||
setSyncing(false);
|
||||
// Refresh sync log
|
||||
const logResult = await getSyncLog(brandId);
|
||||
if (logResult.success) setSyncLog(logResult.logs);
|
||||
}
|
||||
|
||||
async function handleDisconnectSquare() {
|
||||
if (!confirm("Disconnect Square? This will clear the access token.")) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
const result = await savePaymentSettings({
|
||||
brandId: activeBrandId,
|
||||
provider: provider || null,
|
||||
squareAccessToken: "",
|
||||
squareLocationId: "",
|
||||
squareSyncEnabled: false,
|
||||
squareInventoryMode: "none",
|
||||
});
|
||||
setSaving(false);
|
||||
if (!result.success) {
|
||||
setError(result.error ?? "Failed to disconnect");
|
||||
} else {
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 3000);
|
||||
}
|
||||
}
|
||||
|
||||
const lastSyncAt = settings?.square_last_sync_at
|
||||
? new Date(settings.square_last_sync_at).toLocaleString()
|
||||
: "Never";
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSave} className="space-y-8">
|
||||
{/* Platform admin brand picker */}
|
||||
{isPlatformAdmin && brands.length > 0 && (
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-zinc-300">Brand</label>
|
||||
<select
|
||||
value={activeBrandId}
|
||||
onChange={(e) => setActiveBrandId(e.target.value)}
|
||||
className="w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-base text-zinc-100 outline-none focus:border-zinc-400"
|
||||
>
|
||||
{brands.map((b) => (
|
||||
<option key={b.id} value={b.id}>{b.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="rounded-xl bg-red-900/50 border border-red-700 p-4 text-sm text-red-200">{error}</div>
|
||||
)}
|
||||
{saved && (
|
||||
<div className="rounded-xl bg-green-900/50 border border-green-700 p-4 text-sm text-green-200">
|
||||
Payment settings saved.
|
||||
</div>
|
||||
)}
|
||||
{syncResult && (
|
||||
<div className={`rounded-xl p-4 text-sm border ${
|
||||
syncResult.success ? "bg-green-900/50 border-green-700 text-green-200" : "bg-red-900/50 border-red-700 text-red-200"
|
||||
}`}>
|
||||
{syncResult.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Connected status banner */}
|
||||
{settings?.stripe_publishable_key && (
|
||||
<div className="flex items-center gap-3 rounded-xl border border-green-200 bg-green-900/30 px-4 py-3 text-sm">
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-green-900/300 text-white text-xs font-bold">✓</span>
|
||||
<div>
|
||||
<p className="font-semibold text-green-300">Stripe Connected</p>
|
||||
<p className="text-xs text-green-400/70">Payments are configured for this brand</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Provider selection */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-zinc-300">Payment Provider</label>
|
||||
<p className="mt-1 text-sm text-zinc-500">
|
||||
Choose how to process payments for this brand.
|
||||
</p>
|
||||
<div className="mt-3 flex gap-3">
|
||||
{[
|
||||
{ value: "", label: "None / Manual" },
|
||||
{ value: "stripe", label: "Stripe" },
|
||||
{ value: "square", label: "Square" },
|
||||
].map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setProvider(opt.value as PaymentProvider | "");
|
||||
setShowStripe(opt.value === "stripe");
|
||||
setShowSquare(opt.value === "square");
|
||||
}}
|
||||
className={`rounded-xl border px-4 py-3 text-sm font-medium ${
|
||||
provider === opt.value
|
||||
? "border-zinc-400 bg-zinc-700 text-zinc-50"
|
||||
: "border-zinc-600 text-zinc-400 hover:bg-zinc-800"
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stripe credentials */}
|
||||
{showStripe && (
|
||||
<div className="space-y-4 rounded-xl border border-blue-200 bg-blue-900/30 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-semibold text-blue-900">Stripe</h3>
|
||||
{settings?.stripe_publishable_key && (
|
||||
<span className="flex items-center gap-1.5 rounded-full bg-green-900/40 px-2.5 py-1 text-xs font-semibold text-green-400">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-green-900/300"></span>
|
||||
Connected
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300">Publishable Key</label>
|
||||
<input
|
||||
type="text"
|
||||
value={stripePublishableKey}
|
||||
onChange={(e) => setStripePublishableKey(e.target.value)}
|
||||
placeholder="pk_live_..."
|
||||
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-sm text-zinc-100 outline-none focus:border-zinc-400"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300">Secret Key</label>
|
||||
<input
|
||||
type="password"
|
||||
value={stripeSecretKey}
|
||||
onChange={(e) => setStripeSecretKey(e.target.value)}
|
||||
placeholder="sk_live_..."
|
||||
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-sm text-zinc-100 outline-none focus:border-zinc-400"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="rounded-xl bg-blue-600 px-5 py-2.5 text-sm font-bold text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{saving ? "Saving..." : "Save Stripe Settings"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Square credentials */}
|
||||
{showSquare && (
|
||||
<div className="space-y-4 rounded-xl border border-green-200 bg-green-900/30 p-4">
|
||||
<h3 className="font-semibold text-green-900">Square</h3>
|
||||
{!hasSquareToken ? (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-green-400">
|
||||
Connect your Square account via OAuth to enable sync.
|
||||
</p>
|
||||
<a
|
||||
href="/api/square/oauth"
|
||||
className="inline-block rounded-xl bg-green-600 px-5 py-2.5 text-sm font-semibold text-white hover:bg-green-700"
|
||||
>
|
||||
Connect Square
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300">Location ID</label>
|
||||
<input
|
||||
type="text"
|
||||
value={squareLocationId}
|
||||
onChange={(e) => setSquareLocationId(e.target.value)}
|
||||
placeholder="L..."
|
||||
className="mt-1 w-full rounded-xl border border-zinc-600 px-4 py-3 text-sm font-mono outline-none focus:border-slate-900"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDisconnectSquare}
|
||||
className="text-sm text-red-400 hover:underline"
|
||||
>
|
||||
Disconnect Square
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Square Sync section */}
|
||||
{hasSquareToken && provider === "square" && (
|
||||
<div className="space-y-5 rounded-xl border border-purple-200 bg-purple-50 p-5">
|
||||
<div>
|
||||
<h3 className="font-semibold text-purple-900">Square Sync</h3>
|
||||
<p className="mt-1 text-sm text-purple-700">
|
||||
Keep products, orders, and inventory in sync between Route Commerce and Square.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Enable toggle */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSquareSyncEnabled(!squareSyncEnabled)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
squareSyncEnabled ? "bg-purple-600" : "bg-slate-300"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-zinc-900 transition-transform ${
|
||||
squareSyncEnabled ? "translate-x-6" : "translate-x-1"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
<span className="text-sm font-medium text-purple-900">
|
||||
{squareSyncEnabled ? "Enabled" : "Disabled"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{squareSyncEnabled && (
|
||||
<>
|
||||
{/* Inventory mode */}
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-medium text-zinc-300">Inventory sync direction</p>
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||
{[
|
||||
{ value: "none", label: "None" },
|
||||
{ value: "rc_to_square", label: "RC → Square" },
|
||||
{ value: "square_to_rc", label: "Square → RC" },
|
||||
{ value: "bidirectional", label: "Bidirectional" },
|
||||
].map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => setSquareInventoryMode(opt.value as InventoryMode)}
|
||||
className={`rounded-lg border px-3 py-2 text-xs font-medium ${
|
||||
squareInventoryMode === opt.value
|
||||
? "border-purple-600 bg-purple-600 text-white"
|
||||
: "border-zinc-600 text-zinc-400 hover:bg-purple-100"
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sync Now buttons */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSyncNow("products")}
|
||||
disabled={syncing}
|
||||
className="rounded-lg border border-purple-300 bg-zinc-900 px-4 py-2 text-sm font-medium text-purple-700 hover:bg-purple-100 disabled:opacity-50"
|
||||
>
|
||||
{syncing ? "Syncing..." : "Sync Products Now"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSyncNow("orders")}
|
||||
disabled={syncing}
|
||||
className="rounded-lg border border-purple-300 bg-zinc-900 px-4 py-2 text-sm font-medium text-purple-700 hover:bg-purple-100 disabled:opacity-50"
|
||||
>
|
||||
{syncing ? "Syncing..." : "Sync Orders Now"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSyncNow("all")}
|
||||
disabled={syncing}
|
||||
className="rounded-lg bg-purple-600 px-4 py-2 text-sm font-semibold text-white hover:bg-purple-700 disabled:opacity-50"
|
||||
>
|
||||
{syncing ? "Syncing..." : "Sync All Now"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Last sync info */}
|
||||
<div className="text-xs text-zinc-500">
|
||||
Last sync: {lastSyncAt}
|
||||
{settings?.square_last_sync_error && (
|
||||
<span className="ml-2 text-red-500">— {settings.square_last_sync_error}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sync log preview */}
|
||||
{syncLog.length > 0 && (
|
||||
<div>
|
||||
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-zinc-500">
|
||||
Recent sync activity
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{syncLog.map((entry) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className={`flex items-center justify-between rounded-lg border px-3 py-2 text-xs ${
|
||||
entry.status === "success"
|
||||
? "border-green-200 bg-green-900/30 text-green-400"
|
||||
: "border-red-200 bg-red-900/30 text-red-400"
|
||||
}`}
|
||||
>
|
||||
<span>
|
||||
[{entry.direction ?? "—"}] {entry.event_type} — {entry.status}
|
||||
</span>
|
||||
<span className="text-slate-400">
|
||||
{new Date(entry.created_at).toLocaleTimeString()}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Wholesale webhook log */}
|
||||
<WebhookLogsSection brandId={activeBrandId} />
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="rounded-xl bg-zinc-100 px-6 py-3 text-sm font-bold text-zinc-900 hover:bg-zinc-200 disabled:opacity-50"
|
||||
>
|
||||
{saving ? "Saving..." : "Save Payment Settings"}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
type Product = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
price: number;
|
||||
};
|
||||
|
||||
export default function ProductAssignmentForm({
|
||||
stopId,
|
||||
allProducts,
|
||||
assignedProductIds,
|
||||
}: {
|
||||
stopId: string;
|
||||
allProducts: Product[];
|
||||
assignedProductIds: string[];
|
||||
}) {
|
||||
const [selected, setSelected] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [assignedIds, setAssignedIds] = useState<Set<string>>(new Set(assignedProductIds));
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const availableProducts = allProducts.filter(
|
||||
(p) => !assignedIds.has(p.id)
|
||||
);
|
||||
|
||||
async function handleAssign(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!selected) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const res = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/assign_product_to_stop`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
p_stop_id: stopId,
|
||||
p_product_id: selected,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok || data.success === false) {
|
||||
setError(data.error ?? "Failed to assign product");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setAssignedIds((prev) => new Set(prev).add(selected));
|
||||
setSelected("");
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
async function handleRemove(productId: string) {
|
||||
const res = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/unassign_product_from_stop`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
p_stop_id: stopId,
|
||||
p_product_id: productId,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok || data.success === false) {
|
||||
setError(data.error ?? "Failed to remove");
|
||||
return;
|
||||
}
|
||||
|
||||
setAssignedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(productId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
const assignedProducts = allProducts.filter((p) => assignedIds.has(p.id));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{error && (
|
||||
<div className="rounded-xl bg-red-900/30 p-4 text-sm text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Assigned products list */}
|
||||
{assignedProducts.length > 0 && (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-zinc-300 mb-3">
|
||||
Currently assigned
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{assignedProducts.map((product) => (
|
||||
<div
|
||||
key={product.id}
|
||||
className="flex items-center justify-between rounded-xl border border-zinc-800 p-4"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-zinc-100">{product.name}</p>
|
||||
<p className="text-sm text-zinc-500">
|
||||
{product.type} · ${product.price}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleRemove(product.id)}
|
||||
className="text-sm text-red-400 hover:text-red-800"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Assign form */}
|
||||
<form onSubmit={handleAssign} className="flex gap-3">
|
||||
<select
|
||||
value={selected}
|
||||
onChange={(e) => setSelected(e.target.value)}
|
||||
required
|
||||
className="flex-1 rounded-xl border border-zinc-600 px-4 py-3 outline-none focus:border-slate-900"
|
||||
>
|
||||
<option value="">Select a product...</option>
|
||||
{availableProducts.map((product) => (
|
||||
<option key={product.id} value={product.id}>
|
||||
{product.name} — {product.type} (${product.price})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!selected || loading}
|
||||
className="rounded-xl bg-slate-900 px-5 py-3 font-medium text-white disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Assigning..." : "Assign Product"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{availableProducts.length === 0 && assignedProducts.length > 0 && (
|
||||
<p className="text-sm text-zinc-500">All products already assigned.</p>
|
||||
)}
|
||||
|
||||
{allProducts.length === 0 && (
|
||||
<p className="text-sm text-zinc-500">
|
||||
No active products for this brand yet.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
"use client";
|
||||
|
||||
import NextImage from "next/image";
|
||||
import { useState, useRef } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { uploadProductImage, deleteProductImage } from "@/actions/products/upload-image";
|
||||
import { updateProduct } from "@/actions/products/update-product";
|
||||
|
||||
type Brand = {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
};
|
||||
|
||||
type ProductEditFormProps = {
|
||||
product: {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
price: number;
|
||||
type: string;
|
||||
active: boolean;
|
||||
brand_id: string;
|
||||
image_url?: string | null;
|
||||
is_taxable?: boolean;
|
||||
pickup_type?: string;
|
||||
};
|
||||
brands: Brand[];
|
||||
};
|
||||
|
||||
export default function ProductEditForm({ product, brands }: ProductEditFormProps) {
|
||||
const router = useRouter();
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
const [name, setName] = useState(product.name);
|
||||
const [description, setDescription] = useState(product.description);
|
||||
const [price, setPrice] = useState(product.price);
|
||||
const [type, setType] = useState(product.type);
|
||||
const [active, setActive] = useState(product.active);
|
||||
const [brand_id, setBrand_id] = useState(product.brand_id);
|
||||
const [image_url, setImage_url] = useState(product.image_url ?? "");
|
||||
const [is_taxable, setIs_taxable] = useState(product.is_taxable ?? true);
|
||||
const [pickup_type, setPickup_type] = useState(product.pickup_type ?? "scheduled_stop");
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||
const [imagePreview, setImagePreview] = useState<string | null>(product.image_url ?? null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
async function handleFileSelect(file: File) {
|
||||
const validTypes = ["image/png", "image/jpeg", "image/webp"];
|
||||
if (!validTypes.includes(file.type)) {
|
||||
setUploadError("Only PNG, JPEG, and WebP images are allowed.");
|
||||
return;
|
||||
}
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
setUploadError("Image must be under 5MB.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Client-side resize to max 1200px width
|
||||
const resizedBuffer = await resizeImage(file, 1200);
|
||||
const resizedFile = new File([resizedBuffer], file.name, { type: "image/jpeg" });
|
||||
|
||||
setUploadError(null);
|
||||
setUploading(true);
|
||||
const result = await uploadProductImage(product.id, resizedFile);
|
||||
setUploading(false);
|
||||
|
||||
if (result.success) {
|
||||
setImage_url(result.imageUrl);
|
||||
setImagePreview(result.imageUrl);
|
||||
} else {
|
||||
setUploadError(result.error ?? "Upload failed.");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemoveImage() {
|
||||
const result = await deleteProductImage(product.id);
|
||||
if (result.success) {
|
||||
setImage_url("");
|
||||
setImagePreview(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function resizeImage(file: File, maxWidth: number): Promise<ArrayBuffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
let { width, height } = img;
|
||||
if (width > maxWidth) {
|
||||
height = Math.round(height * (maxWidth / width));
|
||||
width = maxWidth;
|
||||
}
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
ctx.drawImage(img, 0, 0, width, height);
|
||||
canvas.toBlob((blob) => {
|
||||
if (!blob) {
|
||||
reject(new Error("Failed to resize image"));
|
||||
return;
|
||||
}
|
||||
blob.arrayBuffer().then((buf) => resolve(buf));
|
||||
}, "image/jpeg", 0.85);
|
||||
};
|
||||
img.onerror = reject;
|
||||
img.src = URL.createObjectURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!name.trim()) {
|
||||
setError("Product name is required.");
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
setSaved(false);
|
||||
|
||||
const result = await updateProduct(product.id, brand_id, {
|
||||
name,
|
||||
description,
|
||||
price: Number(price),
|
||||
type,
|
||||
active,
|
||||
image_url: image_url || null,
|
||||
is_taxable,
|
||||
pickup_type,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
setError(result.error ?? "Failed to save");
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setSaved(true);
|
||||
setSaving(false);
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{error && (
|
||||
<div className="rounded-xl bg-red-900/30 p-4 text-sm text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{saved && (
|
||||
<div className="rounded-xl bg-green-900/30 p-4 text-sm text-green-400">
|
||||
Product updated successfully.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-zinc-300">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base text-zinc-100 outline-none focus:border-slate-900"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-zinc-300">Description</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base text-zinc-100 outline-none focus:border-slate-900"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-zinc-300">Price</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={price}
|
||||
onChange={(e) => setPrice(Number(e.target.value))}
|
||||
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base text-zinc-100 outline-none focus:border-slate-900"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-zinc-300">Type</label>
|
||||
<input
|
||||
type="text"
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value)}
|
||||
placeholder="e.g. Sweet Corn"
|
||||
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base text-zinc-100 outline-none focus:border-slate-900"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-zinc-300">Brand</label>
|
||||
<select
|
||||
value={brand_id}
|
||||
onChange={(e) => setBrand_id(e.target.value)}
|
||||
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base text-zinc-100 outline-none focus:border-slate-900"
|
||||
>
|
||||
{brands.map((b) => (
|
||||
<option key={b.id} value={b.id}>
|
||||
{b.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-zinc-300">Status</label>
|
||||
<button
|
||||
onClick={() => setActive((v) => !v)}
|
||||
className={`w-full rounded-xl px-4 py-3 text-sm font-medium transition-colors ${
|
||||
active
|
||||
? "bg-green-900/40 text-green-400"
|
||||
: "bg-zinc-950 text-zinc-400 hover:bg-slate-200"
|
||||
}`}
|
||||
>
|
||||
{active ? "Active" : "Inactive"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-zinc-300">Taxable</label>
|
||||
<button
|
||||
onClick={() => setIs_taxable((v) => !v)}
|
||||
className={`w-full rounded-xl px-4 py-3 text-sm font-medium transition-colors flex items-center gap-3 ${
|
||||
is_taxable
|
||||
? "bg-emerald-50 text-emerald-700 ring-1 ring-emerald-200"
|
||||
: "bg-amber-900/30 text-amber-700 ring-1 ring-amber-200"
|
||||
}`}
|
||||
>
|
||||
<span className="text-lg">{is_taxable ? "✓" : "✗"}</span>
|
||||
<span>{is_taxable ? "Taxable — tax applied at checkout for nexus shipments" : "Non-taxable — always exempt from sales tax"}</span>
|
||||
</button>
|
||||
<p className="mt-1.5 text-xs text-zinc-500">Tax is calculated at checkout for shipping orders in your brand's nexus states. Non-taxable items (e.g. cooler boxes, apparel) are always exempt.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-zinc-300">Pickup Type</label>
|
||||
<select
|
||||
value={pickup_type}
|
||||
onChange={(e) => setPickup_type(e.target.value)}
|
||||
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base text-zinc-100 outline-none focus:border-slate-900"
|
||||
>
|
||||
<option value="scheduled_stop">Scheduled Stop — requires stop selection at checkout</option>
|
||||
<option value="shed">Shed Pickup — uses description as location</option>
|
||||
</select>
|
||||
<p className="mt-1.5 text-xs text-zinc-500">Shed pickup uses the product description as the pickup location — no stop required at checkout.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-zinc-300">Product Image</label>
|
||||
<p className="text-xs text-zinc-500 mb-2">JPG, PNG, WebP · 1200px max width · max 5MB (ideally under 2MB)</p>
|
||||
|
||||
<div
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file) handleFileSelect(file);
|
||||
}}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className={`
|
||||
flex flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed p-6 cursor-pointer transition-colors
|
||||
${dragOver ? "border-green-500 bg-green-900/30" : "border-zinc-600 hover:border-slate-400 hover:bg-zinc-900"}
|
||||
${uploading ? "opacity-50 pointer-events-none" : ""}
|
||||
`}
|
||||
>
|
||||
{uploading ? (
|
||||
<>
|
||||
<div className="h-5 w-5 rounded-full border-2 border-slate-400 border-t-transparent animate-spin" />
|
||||
<span className="text-sm text-zinc-500">Uploading...</span>
|
||||
</>
|
||||
) : imagePreview ? (
|
||||
<>
|
||||
<span className="relative inline-block h-32 w-auto">
|
||||
<NextImage src={imagePreview} alt="Product preview" fill style={{ objectFit: "contain" }} className="rounded-lg" />
|
||||
</span>
|
||||
<span className="text-xs text-zinc-500">Click or drop to replace</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="h-8 w-8 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<span className="text-sm text-zinc-500">Drag & drop or click to upload</span>
|
||||
</>
|
||||
)}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) handleFileSelect(file);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{uploadError && <p className="mt-1 text-xs text-red-400">{uploadError}</p>}
|
||||
|
||||
{imagePreview && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRemoveImage}
|
||||
className="mt-2 text-xs text-red-500 hover:underline"
|
||||
>
|
||||
Remove image
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="w-full rounded-xl bg-slate-900 px-6 py-4 text-lg font-bold text-white disabled:opacity-50"
|
||||
>
|
||||
{saving ? "Saving..." : "Save Changes"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Product } from "@/types";
|
||||
|
||||
type Props = {
|
||||
products: Product[];
|
||||
onSearchChange: (s: string) => void;
|
||||
onStatusChange: (f: "all" | "active" | "inactive") => void;
|
||||
search: string;
|
||||
statusFilter: "all" | "active" | "inactive";
|
||||
count: number;
|
||||
};
|
||||
|
||||
export default function ProductFilterBar({
|
||||
products,
|
||||
onSearchChange,
|
||||
onStatusChange,
|
||||
search,
|
||||
statusFilter,
|
||||
count,
|
||||
}: Props) {
|
||||
return (
|
||||
<div className="border-b border-slate-100 px-5 py-3 flex gap-3 flex-wrap items-center">
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search products..."
|
||||
value={search}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="flex-1 min-w-40 rounded-lg border border-zinc-600 bg-zinc-900 px-3 py-2 text-sm outline-none focus:border-slate-900"
|
||||
/>
|
||||
<div className="flex gap-1 rounded-lg border border-zinc-600 bg-zinc-900 p-1">
|
||||
{(["all", "active", "inactive"] as const).map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => onStatusChange(f)}
|
||||
className={`rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
|
||||
statusFilter === f
|
||||
? "bg-slate-900 text-white"
|
||||
: "text-zinc-400 hover:bg-zinc-950"
|
||||
}`}
|
||||
>
|
||||
{f === "all" ? "All" : f === "active" ? "Active" : "Inactive"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-xs text-slate-400">{count}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { syncSquareNow, getSyncLog } from "@/actions/square-sync-ui";
|
||||
import Link from "next/link";
|
||||
|
||||
type Props = {
|
||||
brandId: string;
|
||||
hasSquareToken: boolean;
|
||||
};
|
||||
|
||||
export default function ProductSyncBanner({ brandId, hasSquareToken }: Props) {
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [msg, setMsg] = useState<{ kind: "success" | "error"; text: string } | null>(null);
|
||||
const [logs, setLogs] = useState<{ event_type: string; status: string; created_at: string }[]>([]);
|
||||
|
||||
async function handleSyncProducts() {
|
||||
setSyncing(true);
|
||||
setMsg(null);
|
||||
const result = await syncSquareNow(brandId, "products");
|
||||
setMsg({
|
||||
kind: result.success ? "success" : "error",
|
||||
text: result.success
|
||||
? `Products synced — ${result.synced} item(s).`
|
||||
: `Failed: ${result.errors[0] ?? "Unknown error"}`,
|
||||
});
|
||||
setSyncing(false);
|
||||
const logResult = await getSyncLog(brandId);
|
||||
if (logResult.success) setLogs(logResult.logs.filter(l => l.entity_type === "product").slice(0, 5));
|
||||
}
|
||||
|
||||
if (!hasSquareToken) {
|
||||
return (
|
||||
<div className="mb-4 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm">
|
||||
<span className="text-amber-700">Square not connected.</span>
|
||||
<Link href="/admin/settings/payments" className="ml-2 font-medium text-emerald-600 hover:text-emerald-700 underline transition-colors">
|
||||
Connect Square →
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={handleSyncProducts}
|
||||
disabled={syncing}
|
||||
className="rounded-lg border border-emerald-200 bg-white px-4 py-2 text-sm font-medium text-emerald-700 hover:bg-emerald-50 disabled:opacity-50 transition-colors shadow-sm"
|
||||
>
|
||||
{syncing ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<svg className="h-4 w-4 animate-spin" 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>
|
||||
Syncing...
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-2">
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
Sync Products to Square
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<Link
|
||||
href="/admin/settings/square-sync"
|
||||
className="text-sm text-stone-500 hover:text-stone-700 transition-colors"
|
||||
>
|
||||
Square Sync Settings
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{msg && (
|
||||
<div className={`mt-2 rounded-lg border px-3 py-2 text-sm ${
|
||||
msg.kind === "success"
|
||||
? "border-emerald-200 bg-emerald-50 text-emerald-700"
|
||||
: "border-red-200 bg-red-50 text-red-700"
|
||||
}`}>
|
||||
{msg.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{logs.length > 0 && (
|
||||
<div className="mt-2 space-y-1">
|
||||
{logs.map((log, i) => (
|
||||
<div key={i} className="flex items-center gap-2 text-xs text-stone-500">
|
||||
<span className={`rounded px-1.5 py-0.5 font-medium ${
|
||||
log.status === "success" ? "bg-emerald-100 text-emerald-700 border border-emerald-200" : "bg-red-100 text-red-700 border border-red-200"
|
||||
}`}>{log.status}</span>
|
||||
<span className="text-stone-600">{log.event_type}</span>
|
||||
<span className="text-stone-400">{new Date(log.created_at).toLocaleTimeString()}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { deleteProduct } from "@/actions/products";
|
||||
|
||||
type Product = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
price: number;
|
||||
type: string;
|
||||
active: boolean;
|
||||
deleted_at?: string | null;
|
||||
brands: { name: string } | { name: string }[];
|
||||
is_taxable?: boolean;
|
||||
};
|
||||
|
||||
type ProductTableBodyProps = {
|
||||
products: Product[];
|
||||
search: string;
|
||||
statusFilter: "all" | "active" | "inactive";
|
||||
onSearchChange: (v: string) => void;
|
||||
onStatusChange: (v: "all" | "active" | "inactive") => void;
|
||||
onDeleted: (productId: string) => void;
|
||||
};
|
||||
|
||||
export default function ProductTableBody({
|
||||
products,
|
||||
search,
|
||||
statusFilter,
|
||||
onSearchChange,
|
||||
onStatusChange,
|
||||
onDeleted,
|
||||
}: ProductTableBodyProps) {
|
||||
const [openMenu, setOpenMenu] = useState<string | null>(null);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
|
||||
const filtered = products.filter((p) => {
|
||||
const matchesSearch =
|
||||
!search ||
|
||||
p.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
p.description.toLowerCase().includes(search.toLowerCase());
|
||||
const matchesStatus =
|
||||
statusFilter === "all" ||
|
||||
(statusFilter === "active" && p.active) ||
|
||||
(statusFilter === "inactive" && !p.active);
|
||||
return matchesSearch && matchesStatus;
|
||||
});
|
||||
|
||||
async function handleDelete(productId: string) {
|
||||
setDeletingId(productId);
|
||||
setDeleteError(null);
|
||||
const result = await deleteProduct(productId, null);
|
||||
setDeletingId(null);
|
||||
setConfirmDelete(null);
|
||||
setOpenMenu(null);
|
||||
if (result.success) {
|
||||
onDeleted(productId);
|
||||
} else {
|
||||
setDeleteError(result.error ?? "Delete failed");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Filter bar — sits outside <table> in the parent */}
|
||||
<div className="border-b border-slate-100 px-5 py-3 flex gap-3 flex-wrap items-center">
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search products..."
|
||||
value={search}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="flex-1 min-w-40 rounded-lg border border-zinc-600 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 outline-none focus:border-slate-900"
|
||||
/>
|
||||
<div className="flex gap-1 rounded-lg border border-zinc-600 bg-zinc-900 p-1">
|
||||
{(["all", "active", "inactive"] as const).map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => onStatusChange(f)}
|
||||
className={`rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
|
||||
statusFilter === f
|
||||
? "bg-slate-900 text-white"
|
||||
: "text-zinc-400 hover:bg-zinc-950"
|
||||
}`}
|
||||
>
|
||||
{f === "all" ? "All" : f === "active" ? "Active" : "Inactive"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-xs text-slate-400">{filtered.length}</span>
|
||||
</div>
|
||||
|
||||
{/* Delete error banner */}
|
||||
{deleteError && (
|
||||
<div className="mx-5 mt-3 rounded-lg bg-red-900/30 border border-red-200 px-4 py-3 text-sm text-red-400">
|
||||
{deleteError}
|
||||
<button
|
||||
onClick={() => setDeleteError(null)}
|
||||
className="ml-2 underline hover:no-underline"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<tbody className="divide-y divide-slate-200">
|
||||
{filtered.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-5 py-10 text-center text-sm text-zinc-500">
|
||||
{search || statusFilter !== "all"
|
||||
? "No products match your search."
|
||||
: "No products found."}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filtered.map((product) => (
|
||||
<tr
|
||||
key={product.id}
|
||||
className="hover:bg-zinc-800 transition-colors relative"
|
||||
>
|
||||
<td className="px-5 py-4">
|
||||
<Link
|
||||
href={`/admin/products/${product.id}`}
|
||||
className="block font-medium text-zinc-100 hover:text-zinc-400"
|
||||
>
|
||||
{product.name}
|
||||
</Link>
|
||||
<div className="text-zinc-500 line-clamp-1 text-sm">
|
||||
{product.description || <span className="italic text-slate-400">No description</span>}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td className="px-5 py-4 text-zinc-300">
|
||||
{Array.isArray(product.brands)
|
||||
? product.brands[0]?.name
|
||||
: product.brands?.name}
|
||||
</td>
|
||||
|
||||
<td className="px-5 py-4 text-zinc-300">{product.type}</td>
|
||||
|
||||
<td className="px-5 py-4 font-semibold text-zinc-100">
|
||||
${Number(product.price).toFixed(2)}
|
||||
</td>
|
||||
|
||||
<td className="px-5 py-4">
|
||||
<span
|
||||
className={`rounded-full px-3 py-1 text-xs font-medium ${
|
||||
product.active
|
||||
? "bg-green-900/40 text-green-400"
|
||||
: "bg-zinc-950 text-zinc-400"
|
||||
}`}
|
||||
>
|
||||
{product.active ? "Active" : "Inactive"}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<td className="px-5 py-4">
|
||||
{product.is_taxable === false ? (
|
||||
<span className="rounded-full bg-amber-100 px-2.5 py-1 text-xs font-medium text-amber-700">Non-taxable</span>
|
||||
) : (
|
||||
<span className="rounded-full bg-emerald-100 px-2.5 py-1 text-xs font-medium text-emerald-700">Taxable</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* Actions */}
|
||||
<td className="px-5 py-4 text-right">
|
||||
<div className="relative inline-flex items-center justify-end gap-2">
|
||||
<Link
|
||||
href={`/admin/products/${product.id}`}
|
||||
className="rounded-lg px-3 py-1.5 text-xs font-medium text-zinc-300 hover:bg-zinc-950"
|
||||
>
|
||||
Edit
|
||||
</Link>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setOpenMenu(openMenu === product.id ? null : product.id);
|
||||
}}
|
||||
className="rounded-lg px-2 py-1.5 text-xs text-zinc-500 hover:bg-zinc-950"
|
||||
>
|
||||
⋮
|
||||
</button>
|
||||
|
||||
{openMenu === product.id && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-10"
|
||||
onClick={() => { setOpenMenu(null); setConfirmDelete(null); }}
|
||||
/>
|
||||
<div className="absolute right-0 top-full mt-1 z-20 w-44 rounded-xl bg-zinc-900 shadow-lg ring-1 ring-zinc-700 overflow-hidden">
|
||||
<button
|
||||
onClick={() => { setOpenMenu(null); setConfirmDelete(product.id); }}
|
||||
className="w-full text-left px-4 py-2.5 text-sm text-red-400 hover:bg-red-900/30"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{confirmDelete === product.id && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-30 bg-black/60 backdrop-blur-sm"
|
||||
onClick={() => { setConfirmDelete(null); setOpenMenu(null); }}
|
||||
/>
|
||||
<div className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-40 w-80 rounded-xl bg-zinc-900 shadow-xl ring-1 ring-zinc-700 p-6">
|
||||
<p className="text-sm font-semibold text-zinc-100">
|
||||
Delete "{product.name}"?
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-zinc-500">
|
||||
This will remove the product. If it is attached to any
|
||||
orders, it will be hidden instead of deleted.
|
||||
</p>
|
||||
<div className="mt-5 flex gap-3">
|
||||
<button
|
||||
onClick={() => { setConfirmDelete(null); setOpenMenu(null); }}
|
||||
className="flex-1 rounded-lg border border-zinc-600 px-3 py-2 text-sm font-medium text-zinc-300 hover:bg-zinc-800"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(product.id)}
|
||||
disabled={deletingId === product.id}
|
||||
className="flex-1 rounded-lg bg-red-600 px-3 py-2 text-sm font-medium text-white hover:bg-red-700 disabled:opacity-50"
|
||||
>
|
||||
{deletingId === product.id ? "..." : "Delete"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useTransition } from "react";
|
||||
import Image from "next/image";
|
||||
import { deleteProduct } from "@/actions/products";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
type Product = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
price: number;
|
||||
type: string;
|
||||
active: boolean;
|
||||
deleted_at?: string | null;
|
||||
image_url?: string | null;
|
||||
brands: { name: string } | { name: string }[];
|
||||
};
|
||||
|
||||
type Props = {
|
||||
products: Product[];
|
||||
};
|
||||
|
||||
export default function ProductTableClient({ products }: Props) {
|
||||
const router = useRouter();
|
||||
const [, startTransition] = useTransition();
|
||||
const [search, setSearch] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<"all" | "active" | "inactive">("all");
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
|
||||
const filtered = products.filter((p) => {
|
||||
const matchesSearch =
|
||||
!search ||
|
||||
p.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
p.description.toLowerCase().includes(search.toLowerCase());
|
||||
const matchesStatus =
|
||||
statusFilter === "all" ||
|
||||
(statusFilter === "active" && p.active) ||
|
||||
(statusFilter === "inactive" && !p.active);
|
||||
return matchesSearch && matchesStatus;
|
||||
});
|
||||
|
||||
function handleDeleted() {
|
||||
setDeleteError(null);
|
||||
startTransition(() => {
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Filter bar */}
|
||||
<div className="px-5 py-4 flex gap-3 flex-wrap items-center border-b border-stone-200">
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search products..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="flex-1 min-w-48 rounded-lg border border-stone-200 bg-stone-50 px-3 py-2 text-sm text-stone-900 outline-none focus:border-emerald-500 placeholder:text-stone-400 transition-colors"
|
||||
/>
|
||||
<div className="flex gap-1 rounded-lg border border-stone-200 bg-white p-1">
|
||||
{(["all", "active", "inactive"] as const).map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setStatusFilter(f)}
|
||||
className={`rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
|
||||
statusFilter === f
|
||||
? "bg-emerald-100 text-emerald-700 border border-emerald-200"
|
||||
: "text-stone-500 hover:text-stone-700 hover:bg-stone-50"
|
||||
}`}
|
||||
>
|
||||
{f === "all" ? "All" : f === "active" ? "Active" : "Inactive"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-sm text-stone-500 px-2">{filtered.length} products</span>
|
||||
</div>
|
||||
|
||||
{/* Delete error */}
|
||||
{deleteError && (
|
||||
<div className="mx-5 my-3 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{deleteError}{" "}
|
||||
<button onClick={() => setDeleteError(null)} className="underline hover:no-underline">
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="border-b border-stone-200 bg-stone-50">
|
||||
<tr>
|
||||
<th className="px-5 py-4 font-semibold text-stone-500">Product</th>
|
||||
<th className="px-5 py-4 font-semibold text-stone-500">Brand</th>
|
||||
<th className="px-5 py-4 font-semibold text-stone-500">Type</th>
|
||||
<th className="px-5 py-4 font-semibold text-stone-500">Price</th>
|
||||
<th className="px-5 py-4 font-semibold text-stone-500">Status</th>
|
||||
<th className="px-5 py-4 font-semibold text-stone-500" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-stone-100">
|
||||
{filtered.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-5 py-16 text-center text-sm text-stone-500">
|
||||
{search || statusFilter !== "all"
|
||||
? "No products match your search."
|
||||
: "No products found."}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filtered.map((product) => (
|
||||
<ProductRow
|
||||
key={product.id}
|
||||
product={product}
|
||||
onDeleted={handleDeleted}
|
||||
onDeleteError={setDeleteError}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ProductRowBase({
|
||||
product,
|
||||
onDeleted,
|
||||
onDeleteError,
|
||||
}: {
|
||||
product: Product;
|
||||
onDeleted: () => void;
|
||||
onDeleteError: (msg: string) => void;
|
||||
}) {
|
||||
const [openMenu, setOpenMenu] = useState<string | null>(null);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
|
||||
|
||||
async function handleDelete(productId: string) {
|
||||
setDeletingId(productId);
|
||||
const result = await deleteProduct(productId, null);
|
||||
setDeletingId(null);
|
||||
setConfirmDelete(null);
|
||||
setOpenMenu(null);
|
||||
if (result.success) {
|
||||
onDeleted();
|
||||
} else {
|
||||
onDeleteError(result.error ?? "Delete failed");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<tr className="hover:bg-stone-50 transition-colors relative">
|
||||
<td className="px-5 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{product.image_url ? (
|
||||
<div className="relative h-12 w-12 shrink-0 overflow-hidden rounded-lg border border-stone-200">
|
||||
<Image
|
||||
src={product.image_url}
|
||||
alt={product.name}
|
||||
fill
|
||||
style={{ objectFit: "cover" }}
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-12 w-12 rounded-lg bg-stone-100 flex items-center justify-center shrink-0 border border-stone-200">
|
||||
<span className="text-stone-400 text-lg">□</span>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<Link
|
||||
href={`/admin/products/${product.id}`}
|
||||
className="block font-medium text-stone-900 hover:text-emerald-600 transition-colors"
|
||||
>
|
||||
{product.name}
|
||||
</Link>
|
||||
<div className="text-stone-500 line-clamp-1 text-sm">
|
||||
{product.description || (
|
||||
<span className="italic text-stone-400">No description</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td className="px-5 py-4 text-stone-600">
|
||||
{Array.isArray(product.brands)
|
||||
? product.brands[0]?.name
|
||||
: product.brands?.name}
|
||||
</td>
|
||||
|
||||
<td className="px-5 py-4 text-stone-600 font-mono text-xs uppercase tracking-wide">{product.type}</td>
|
||||
|
||||
<td className="px-5 py-4 font-mono font-semibold text-stone-900">
|
||||
${Number(product.price).toFixed(2)}
|
||||
</td>
|
||||
|
||||
<td className="px-5 py-4">
|
||||
<span
|
||||
className={`rounded-full px-3 py-1 text-xs font-medium ${
|
||||
product.active
|
||||
? "bg-emerald-100 text-emerald-700 border border-emerald-200"
|
||||
: "bg-stone-100 text-stone-600 border border-stone-200"
|
||||
}`}
|
||||
>
|
||||
{product.active ? "Active" : "Inactive"}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<td className="px-5 py-4 text-right">
|
||||
<div className="relative inline-flex items-center justify-end gap-2">
|
||||
<Link
|
||||
href={`/admin/products/${product.id}`}
|
||||
className="rounded-lg px-3 py-1.5 text-xs font-medium text-stone-600 hover:text-stone-900 hover:bg-stone-100 transition-colors"
|
||||
>
|
||||
Edit
|
||||
</Link>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setOpenMenu(openMenu === product.id ? null : product.id);
|
||||
}}
|
||||
className="rounded-lg px-2 py-1.5 text-xs text-stone-400 hover:text-stone-600 hover:bg-stone-100 transition-colors"
|
||||
>
|
||||
⋮
|
||||
</button>
|
||||
|
||||
{openMenu === product.id && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-10"
|
||||
onClick={() => { setOpenMenu(null); setConfirmDelete(null); }}
|
||||
/>
|
||||
<div className="absolute right-0 top-full mt-1 z-20 w-44 rounded-xl bg-white border border-stone-200 shadow-xl overflow-hidden">
|
||||
<button
|
||||
onClick={() => { setOpenMenu(null); setConfirmDelete(product.id); }}
|
||||
className="w-full text-left px-4 py-2.5 text-sm text-red-600 hover:bg-red-50 transition-colors"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{confirmDelete === product.id && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-30"
|
||||
onClick={() => { setConfirmDelete(null); setOpenMenu(null); }}
|
||||
/>
|
||||
<div className="absolute right-0 top-full mt-1 z-40 w-72 rounded-xl bg-white border border-stone-200 shadow-xl p-4">
|
||||
<p className="text-sm font-semibold text-stone-900">
|
||||
Delete "{product.name}"?
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-stone-500">
|
||||
This will remove the product. If it is attached to any orders,
|
||||
it will be hidden instead of deleted.
|
||||
</p>
|
||||
<div className="mt-4 flex gap-2">
|
||||
<button
|
||||
onClick={() => { setConfirmDelete(null); setOpenMenu(null); }}
|
||||
className="flex-1 rounded-lg border border-stone-200 bg-white px-3 py-2 text-xs font-medium text-stone-700 hover:bg-stone-50 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(product.id)}
|
||||
disabled={deletingId === product.id}
|
||||
className="flex-1 rounded-lg bg-red-600 hover:bg-red-500 px-3 py-2 text-xs font-medium text-white disabled:opacity-50 transition-colors shadow-sm"
|
||||
>
|
||||
{deletingId === product.id ? "..." : "Delete"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
const ProductRow = React.memo(ProductRowBase);
|
||||
@@ -0,0 +1,729 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import {
|
||||
getReportsSummary,
|
||||
getOrdersByStopReport,
|
||||
getSalesByProductReport,
|
||||
getFulfillmentReport,
|
||||
getPickupStatusByStop,
|
||||
getContactGrowthReport,
|
||||
getCampaignActivityReport,
|
||||
} from "@/actions/reports";
|
||||
import {
|
||||
exportOrdersByStop,
|
||||
exportSalesByProduct,
|
||||
exportPickupStatus,
|
||||
exportContactGrowth,
|
||||
exportFulfillment,
|
||||
exportCampaignActivity,
|
||||
type ReportsSummary,
|
||||
type OrderByStop,
|
||||
type SalesByProduct,
|
||||
type FulfillmentRow,
|
||||
type PickupStatusByStop,
|
||||
type ContactGrowthRow,
|
||||
type CampaignActivityRow,
|
||||
} from "@/lib/reports-export";
|
||||
import { formatDate, formatDateRange } from "@/lib/date-utils";
|
||||
|
||||
type DatePreset = "week" | "month" | "quarter" | "this_year" | "custom";
|
||||
|
||||
type DateRange = { start: string; end: string };
|
||||
|
||||
function buildRange(preset: DatePreset, customStart?: string, customEnd?: string): DateRange {
|
||||
const now = new Date();
|
||||
const toDateStr = (d: Date) => d.toISOString().slice(0, 10);
|
||||
const today = toDateStr(now);
|
||||
|
||||
switch (preset) {
|
||||
case "week": {
|
||||
const s = new Date(now);
|
||||
s.setDate(s.getDate() - 6);
|
||||
return { start: toDateStr(s), end: today };
|
||||
}
|
||||
case "month": {
|
||||
const start = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
return { start: toDateStr(start), end: today };
|
||||
}
|
||||
case "quarter": {
|
||||
const q = Math.floor(now.getMonth() / 3);
|
||||
const start = new Date(now.getFullYear(), q * 3, 1);
|
||||
return { start: toDateStr(start), end: today };
|
||||
}
|
||||
case "this_year": {
|
||||
const start = new Date(now.getFullYear(), 0, 1);
|
||||
return { start: toDateStr(start), end: today };
|
||||
}
|
||||
case "custom":
|
||||
return { start: customStart ?? today, end: customEnd ?? today };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Quarter label helper ──────────────────────────────────────────────────────
|
||||
function quarterLabel(startISO: string): string {
|
||||
const s = new Date(startISO + "T00:00:00");
|
||||
const q = Math.floor(s.getMonth() / 3) + 1;
|
||||
return `Q${q} ${s.getFullYear()}`;
|
||||
}
|
||||
|
||||
function monthLabel(startISO: string): string {
|
||||
const s = new Date(startISO + "T00:00:00");
|
||||
return s.toLocaleDateString("en-US", { month: "long", year: "numeric" });
|
||||
}
|
||||
|
||||
// ── Summary card ─────────────────────────────────────────────────────────────
|
||||
|
||||
function SummaryCard({
|
||||
label,
|
||||
value,
|
||||
prefix,
|
||||
suffix,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
prefix?: string;
|
||||
suffix?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-xl border border-zinc-800 bg-zinc-900 px-4 py-4">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-zinc-500">{label}</p>
|
||||
<p className="mt-1 text-2xl font-semibold text-zinc-100">
|
||||
{prefix ?? ""}{typeof value === "number" ? value.toLocaleString() : value}{suffix ?? ""}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── CSV export button ───────────────────────────────────────────────────────
|
||||
|
||||
function ExportBtn({
|
||||
label,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="rounded-lg border border-zinc-600 px-3 py-1.5 text-xs font-medium text-zinc-400 hover:bg-zinc-800"
|
||||
>
|
||||
Export CSV
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function ExplainBtn({
|
||||
onClick,
|
||||
loading,
|
||||
}: {
|
||||
onClick: () => void;
|
||||
loading: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={loading}
|
||||
className="rounded-lg border border-violet-300 bg-violet-50 px-3 py-1.5 text-xs font-medium text-violet-700 hover:bg-violet-100 disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Analyzing..." : "🤖 Explain this Report"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Generic table ───────────────────────────────────────────────────────────
|
||||
|
||||
function ReportTable({ headers, rows, renderRow }: {
|
||||
headers: string[];
|
||||
rows: unknown[];
|
||||
renderRow: (row: unknown, i: number) => React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-800 bg-slate-50">
|
||||
{headers.map((h) => (
|
||||
<th key={h} className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-zinc-500">
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={headers.length} className="px-3 py-6 text-center text-slate-400">
|
||||
No data
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
rows.map((_, i) => renderRow(_, i))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Tab definitions ─────────────────────────────────────────────────────────
|
||||
|
||||
type Tab = {
|
||||
id: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
const TABS: Tab[] = [
|
||||
{ id: "overview", label: "Overview" },
|
||||
{ id: "orders-by-stop", label: "Orders by Stop" },
|
||||
{ id: "sales-by-product", label: "Sales by Product" },
|
||||
{ id: "fulfillment", label: "Fulfillment" },
|
||||
{ id: "pickup-status", label: "Pickup Status" },
|
||||
{ id: "contact-growth", label: "Contact Growth" },
|
||||
{ id: "campaigns", label: "Campaigns" },
|
||||
];
|
||||
|
||||
// ── Main component ──────────────────────────────────────────────────────────
|
||||
|
||||
export default function ReportsDashboard({
|
||||
brands,
|
||||
initialBrandId,
|
||||
isPlatformAdmin,
|
||||
brandId,
|
||||
}: {
|
||||
brands: { id: string; name: string }[];
|
||||
initialBrandId: string | null;
|
||||
isPlatformAdmin: boolean;
|
||||
brandId?: string | null;
|
||||
}) {
|
||||
const [preset, setPreset] = useState<DatePreset>("quarter");
|
||||
const [customStart, setCustomStart] = useState("");
|
||||
const [customEnd, setCustomEnd] = useState("");
|
||||
const [selectedBrandId, setSelectedBrandId] = useState<string>(initialBrandId ?? "");
|
||||
const [activeTab, setActiveTab] = useState("overview");
|
||||
const [summary, setSummary] = useState<ReportsSummary | null>(null);
|
||||
const [ordersByStop, setOrdersByStop] = useState<OrderByStop[]>([]);
|
||||
const [salesByProduct, setSalesByProduct] = useState<SalesByProduct[]>([]);
|
||||
const [fulfillment, setFulfillment] = useState<FulfillmentRow[]>([]);
|
||||
const [pickupStatus, setPickupStatus] = useState<PickupStatusByStop[]>([]);
|
||||
const [contactGrowth, setContactGrowth] = useState<ContactGrowthRow[]>([]);
|
||||
const [campaigns, setCampaigns] = useState<CampaignActivityRow[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [explainingTab, setExplainingTab] = useState<string | null>(null);
|
||||
const [explanation, setExplanation] = useState<{ summary: string; keyInsights: string[]; suggestedActions: string[] } | null>(null);
|
||||
const [explainError, setExplainError] = useState<string | null>(null);
|
||||
|
||||
const range: DateRange = buildRange(preset, customStart, customEnd);
|
||||
|
||||
// Stable memoized fetchAll — re-fetches when range or brand changes
|
||||
const fetchAll = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const brandId = selectedBrandId || null;
|
||||
|
||||
try {
|
||||
const [sum, obs, sbp, ful, ps, cg, cam] = await Promise.all([
|
||||
getReportsSummary(range, selectedBrandId || null),
|
||||
getOrdersByStopReport(range, selectedBrandId || null),
|
||||
getSalesByProductReport(range, selectedBrandId || null),
|
||||
getFulfillmentReport(range, selectedBrandId || null),
|
||||
getPickupStatusByStop(range, selectedBrandId || null),
|
||||
getContactGrowthReport(range, selectedBrandId || null),
|
||||
getCampaignActivityReport(range, selectedBrandId || null),
|
||||
]);
|
||||
setSummary(sum);
|
||||
setOrdersByStop(obs);
|
||||
setSalesByProduct(sbp);
|
||||
setFulfillment(ful);
|
||||
setPickupStatus(ps);
|
||||
setContactGrowth(cg);
|
||||
setCampaigns(cam);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load reports");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
}, [range, selectedBrandId]);
|
||||
|
||||
const handleFetch = () => {
|
||||
fetchAll();
|
||||
};
|
||||
|
||||
// ── AI Report Explainer ────────────────────────────────────────────────────────
|
||||
|
||||
async function handleExplain() {
|
||||
setExplainingTab(activeTab);
|
||||
setExplanation(null);
|
||||
setExplainError(null);
|
||||
|
||||
const tabDataMap: Record<string, unknown[]> = {
|
||||
"orders-by-stop": ordersByStop,
|
||||
"sales-by-product": salesByProduct,
|
||||
"fulfillment": fulfillment,
|
||||
"pickup-status": pickupStatus,
|
||||
"contact-growth": contactGrowth,
|
||||
"campaigns": campaigns,
|
||||
};
|
||||
|
||||
const data = tabDataMap[activeTab] ?? [];
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/ai/report-explainer", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
reportType: activeTab,
|
||||
dateRange: range,
|
||||
brandId: brandId ?? selectedBrandId,
|
||||
reportData: data,
|
||||
}),
|
||||
});
|
||||
const result = await res.json();
|
||||
if (!res.ok) throw new Error(result.error ?? "Analysis failed");
|
||||
setExplanation(result);
|
||||
} catch (err) {
|
||||
setExplainError(err instanceof Error ? err.message : "Analysis failed");
|
||||
} finally {
|
||||
setExplainingTab(null);
|
||||
}
|
||||
}
|
||||
|
||||
// Close explanation panel
|
||||
function closeExplanation() {
|
||||
setExplanation(null);
|
||||
setExplainError(null);
|
||||
}
|
||||
|
||||
function handleExportCSV(exportFn: () => string, filename: string) {
|
||||
const csv = exportFn();
|
||||
const blob = new Blob([csv], { type: "text/csv" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
// Auto-fetch when brand changes
|
||||
useEffect(() => {
|
||||
fetchAll();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedBrandId]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* ── Error banner ─────────────────────────────────────────────────── */}
|
||||
{error && (
|
||||
<div className="rounded-xl bg-red-900/30 border border-red-200 px-4 py-3 text-sm text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Filters ────────────────────────────────────────────────────────── */}
|
||||
<div className="flex flex-wrap items-center gap-3 rounded-xl bg-zinc-900 border border-zinc-800 px-5 py-4">
|
||||
{/* Date preset */}
|
||||
<div className="flex gap-1.5">
|
||||
{(["week", "month", "quarter", "this_year", "custom"] as DatePreset[]).map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => setPreset(p)}
|
||||
className={`rounded-xl px-4 py-2 text-sm font-semibold transition-all ${
|
||||
preset === p
|
||||
? "bg-blue-600 text-white shadow-black/20"
|
||||
: "bg-zinc-950 text-zinc-400 hover:bg-slate-200"
|
||||
}`}
|
||||
>
|
||||
{p === "week" ? "This Week" : p === "month" ? "This Month" : p === "quarter" ? "This Quarter" : p === "this_year" ? "This Year" : "Custom"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Custom date inputs */}
|
||||
{preset === "custom" && (
|
||||
<div className="flex gap-2 items-center">
|
||||
<input
|
||||
type="date"
|
||||
value={customStart}
|
||||
onChange={(e) => setCustomStart(e.target.value)}
|
||||
className="rounded-lg border border-zinc-600 px-3 py-1.5 text-xs"
|
||||
/>
|
||||
<span className="text-slate-400 text-xs">to</span>
|
||||
<input
|
||||
type="date"
|
||||
value={customEnd}
|
||||
onChange={(e) => setCustomEnd(e.target.value)}
|
||||
className="rounded-lg border border-zinc-600 px-3 py-1.5 text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Brand filter (platform_admin only) */}
|
||||
{isPlatformAdmin && (
|
||||
<select
|
||||
value={selectedBrandId}
|
||||
onChange={(e) => setSelectedBrandId(e.target.value)}
|
||||
className="rounded-lg border border-zinc-600 px-3 py-1.5 text-xs"
|
||||
>
|
||||
<option value="">All Brands</option>
|
||||
{brands.map((b) => (
|
||||
<option key={b.id} value={b.id}>{b.name}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleFetch}
|
||||
disabled={loading}
|
||||
className="rounded-lg bg-blue-600 px-4 py-1.5 text-xs font-medium text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Loading..." : "Refresh"}
|
||||
</button>
|
||||
|
||||
<span className="ml-auto text-sm font-medium text-zinc-500">
|
||||
{preset === "quarter" ? quarterLabel(range.start) :
|
||||
preset === "this_year" ? `${new Date().getFullYear()} YTD` :
|
||||
preset === "month" ? monthLabel(range.start) :
|
||||
preset === "custom" ? `${range.start} → ${range.end}` :
|
||||
formatDateRange(range.start, range.end)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* ── Tab bar ────────────────────────────────────────────────────────── */}
|
||||
<div className="flex gap-1 border-b border-zinc-800">
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px ${
|
||||
activeTab === tab.id
|
||||
? "border-blue-600 text-blue-400"
|
||||
: "border-transparent text-zinc-500 hover:text-zinc-300"
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── Tab content ──────────────────────────────────────────────────────── */}
|
||||
|
||||
{/* OVERVIEW */}
|
||||
{activeTab === "overview" && summary && (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<SummaryCard label="Gross Sales" value={summary.gross_sales} prefix="$" />
|
||||
<SummaryCard label="Total Orders" value={summary.total_orders} />
|
||||
<SummaryCard label="Avg Order Value" value={summary.avg_order_value} prefix="$" />
|
||||
<SummaryCard label="Contacts Added" value={summary.contacts_added} />
|
||||
<SummaryCard label="Pickup Orders" value={summary.pickup_orders} />
|
||||
<SummaryCard label="Shipping Orders" value={summary.shipping_orders} />
|
||||
<SummaryCard label="Pending Pickups" value={summary.pending_pickups} />
|
||||
<SummaryCard label="Completed Pickups" value={summary.completed_pickups} />
|
||||
<SummaryCard label="Campaigns Sent" value={summary.campaigns_sent} />
|
||||
<SummaryCard label="Messages Logged" value={summary.messages_logged} />
|
||||
</div>
|
||||
<p className="text-xs text-slate-400">
|
||||
{formatDateRange(range.start, range.end)}
|
||||
{selectedBrandId ? ` · Brand: ${brands.find(b => b.id === selectedBrandId)?.name ?? selectedBrandId}` : " · All Brands"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "overview" && !summary && !loading && (
|
||||
<div className="rounded-xl border border-zinc-800 bg-zinc-900 px-6 py-12 text-center text-slate-400">
|
||||
Click Refresh to load summary data
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ORDERS BY STOP */}
|
||||
{activeTab === "orders-by-stop" && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-zinc-300">Orders by Stop</h3>
|
||||
<div className="flex gap-2">
|
||||
<ExplainBtn onClick={handleExplain} loading={explainingTab === "orders-by-stop"} />
|
||||
{ordersByStop.length > 0 && (
|
||||
<ExportBtn
|
||||
label="Export CSV"
|
||||
onClick={() => handleExportCSV(() => exportOrdersByStop(ordersByStop), `orders-by-stop-${range.start}.csv`)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-zinc-800 bg-zinc-900 overflow-hidden">
|
||||
<ReportTable
|
||||
headers={["Stop", "City", "State", "Date", "Orders", "Gross Sales", "Pending", "Completed"]}
|
||||
rows={ordersByStop}
|
||||
renderRow={(row) => {
|
||||
const r = row as OrderByStop;
|
||||
return (
|
||||
<tr key={r.stop_name + r.date} className="border-t border-slate-100 hover:bg-zinc-800">
|
||||
<td className="px-3 py-2 font-medium text-zinc-100">{r.stop_name}</td>
|
||||
<td className="px-3 py-2 text-zinc-400">{r.city}</td>
|
||||
<td className="px-3 py-2 text-zinc-400">{r.state}</td>
|
||||
<td className="px-3 py-2 text-zinc-400">{formatDate(r.date)}</td>
|
||||
<td className="px-3 py-2 text-right font-medium">{r.order_count}</td>
|
||||
<td className="px-3 py-2 text-right">${r.gross_sales.toFixed(2)}</td>
|
||||
<td className="px-3 py-2 text-right text-amber-400">{r.pending_count}</td>
|
||||
<td className="px-3 py-2 text-right text-green-600">{r.completed_count}</td>
|
||||
</tr>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SALES BY PRODUCT */}
|
||||
{activeTab === "sales-by-product" && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-zinc-300">Sales by Product</h3>
|
||||
<div className="flex gap-2">
|
||||
<ExplainBtn onClick={handleExplain} loading={explainingTab === "sales-by-product"} />
|
||||
{salesByProduct.length > 0 && (
|
||||
<ExportBtn
|
||||
label="Export CSV"
|
||||
onClick={() => handleExportCSV(() => exportSalesByProduct(salesByProduct), `sales-by-product-${range.start}.csv`)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-zinc-800 bg-zinc-900 overflow-hidden">
|
||||
<ReportTable
|
||||
headers={["Product", "Units Sold", "Gross Revenue", "Avg Price"]}
|
||||
rows={salesByProduct}
|
||||
renderRow={(row) => {
|
||||
const r = row as SalesByProduct;
|
||||
return (
|
||||
<tr key={r.product_name} className="border-t border-slate-100 hover:bg-zinc-800">
|
||||
<td className="px-3 py-2 font-medium text-zinc-100">{r.product_name}</td>
|
||||
<td className="px-3 py-2 text-right">{r.units_sold}</td>
|
||||
<td className="px-3 py-2 text-right font-medium">${r.gross_revenue.toFixed(2)}</td>
|
||||
<td className="px-3 py-2 text-right text-zinc-500">${r.avg_price}</td>
|
||||
</tr>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* FULFILLMENT */}
|
||||
{activeTab === "fulfillment" && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-zinc-300">Fulfillment Breakdown</h3>
|
||||
<div className="flex gap-2">
|
||||
<ExplainBtn onClick={handleExplain} loading={explainingTab === "fulfillment"} />
|
||||
{fulfillment.length > 0 && (
|
||||
<ExportBtn
|
||||
label="Export CSV"
|
||||
onClick={() => handleExportCSV(() => exportFulfillment(fulfillment), `fulfillment-${range.start}.csv`)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-zinc-800 bg-zinc-900 overflow-hidden">
|
||||
<ReportTable
|
||||
headers={["Fulfillment Type", "Order Count", "Revenue", "% of Total"]}
|
||||
rows={fulfillment}
|
||||
renderRow={(row) => {
|
||||
const r = row as FulfillmentRow;
|
||||
return (
|
||||
<tr key={r.fulfillment_type} className="border-t border-slate-100 hover:bg-zinc-800">
|
||||
<td className="px-3 py-2">
|
||||
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${
|
||||
r.fulfillment_type === "pickup" ? "bg-blue-900/40 text-blue-700" :
|
||||
r.fulfillment_type === "shipping" ? "bg-purple-100 text-purple-700" :
|
||||
"bg-zinc-950 text-zinc-300"
|
||||
}`}>{r.fulfillment_type}</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">{r.order_count}</td>
|
||||
<td className="px-3 py-2 text-right font-medium">${r.revenue.toFixed(2)}</td>
|
||||
<td className="px-3 py-2 text-right text-zinc-500">{r.pct_of_total}%</td>
|
||||
</tr>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* PICKUP STATUS */}
|
||||
{activeTab === "pickup-status" && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-zinc-300">Pickup Status by Stop</h3>
|
||||
<div className="flex gap-2">
|
||||
<ExplainBtn onClick={handleExplain} loading={explainingTab === "pickup-status"} />
|
||||
{pickupStatus.length > 0 && (
|
||||
<ExportBtn
|
||||
label="Export CSV"
|
||||
onClick={() => handleExportCSV(() => exportPickupStatus(pickupStatus), `pickup-status-${range.start}.csv`)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-zinc-800 bg-zinc-900 overflow-hidden">
|
||||
<ReportTable
|
||||
headers={["Stop", "City", "Date", "Total Orders", "Pending", "Completed", "Canceled"]}
|
||||
rows={pickupStatus}
|
||||
renderRow={(row) => {
|
||||
const r = row as PickupStatusByStop;
|
||||
return (
|
||||
<tr key={r.stop_name + r.date} className="border-t border-slate-100 hover:bg-zinc-800">
|
||||
<td className="px-3 py-2 font-medium text-zinc-100">{r.stop_name}</td>
|
||||
<td className="px-3 py-2 text-zinc-400">{r.city}</td>
|
||||
<td className="px-3 py-2 text-zinc-400">{formatDate(r.date)}</td>
|
||||
<td className="px-3 py-2 text-right font-medium">{r.total_orders}</td>
|
||||
<td className="px-3 py-2 text-right text-amber-400">{r.pending}</td>
|
||||
<td className="px-3 py-2 text-right text-green-600">{r.completed}</td>
|
||||
<td className="px-3 py-2 text-right text-slate-400">{r.canceled}</td>
|
||||
</tr>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CONTACT GROWTH */}
|
||||
{activeTab === "contact-growth" && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-zinc-300">Contact Growth</h3>
|
||||
<div className="flex gap-2">
|
||||
<ExplainBtn onClick={handleExplain} loading={explainingTab === "contact-growth"} />
|
||||
{contactGrowth.length > 0 && (
|
||||
<ExportBtn
|
||||
label="Export CSV"
|
||||
onClick={() => handleExportCSV(() => exportContactGrowth(contactGrowth), `contact-growth-${range.start}.csv`)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-zinc-800 bg-zinc-900 overflow-hidden">
|
||||
<ReportTable
|
||||
headers={["Date", "New Contacts", "Imports", "Total"]}
|
||||
rows={contactGrowth}
|
||||
renderRow={(row) => {
|
||||
const r = row as ContactGrowthRow;
|
||||
return (
|
||||
<tr key={r.date} className="border-t border-slate-100 hover:bg-zinc-800">
|
||||
<td className="px-3 py-2 text-zinc-300">{r.date}</td>
|
||||
<td className="px-3 py-2 text-right">{r.new_contacts}</td>
|
||||
<td className="px-3 py-2 text-right text-blue-400">{r.imports}</td>
|
||||
<td className="px-3 py-2 text-right font-medium">{r.total}</td>
|
||||
</tr>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CAMPAIGNS */}
|
||||
{activeTab === "campaigns" && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-zinc-300">Campaign Activity</h3>
|
||||
<div className="flex gap-2">
|
||||
<ExplainBtn onClick={handleExplain} loading={explainingTab === "campaigns"} />
|
||||
{campaigns.length > 0 && (
|
||||
<ExportBtn
|
||||
label="Export CSV"
|
||||
onClick={() => handleExportCSV(() => exportCampaignActivity(campaigns), `campaigns-${range.start}.csv`)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-zinc-800 bg-zinc-900 overflow-hidden">
|
||||
<ReportTable
|
||||
headers={["Campaign", "Status", "Type", "Sent At", "Messages Logged"]}
|
||||
rows={campaigns}
|
||||
renderRow={(row) => {
|
||||
const r = row as CampaignActivityRow;
|
||||
return (
|
||||
<tr key={r.campaign_name} className="border-t border-slate-100 hover:bg-zinc-800">
|
||||
<td className="px-3 py-2 font-medium text-zinc-100">{r.campaign_name}</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${
|
||||
r.status === "sent" ? "bg-green-900/40 text-green-400" :
|
||||
r.status === "draft" ? "bg-zinc-950 text-zinc-400" :
|
||||
"bg-amber-100 text-amber-700"
|
||||
}`}>{r.status}</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-zinc-500 text-xs">{r.campaign_type}</td>
|
||||
<td className="px-3 py-2 text-zinc-500 text-xs">{r.sent_at ?? "—"}</td>
|
||||
<td className="px-3 py-2 text-right">{r.messages_logged}</td>
|
||||
</tr>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── AI Explanation Panel ──────────────────────────────────────────────── */}
|
||||
{explanation && (
|
||||
<div className="rounded-2xl border border-violet-200 bg-gradient-to-br from-violet-50 to-white shadow-lg overflow-hidden">
|
||||
<div className="bg-violet-600 px-5 py-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg">🤖</span>
|
||||
<h3 className="font-semibold text-white">AI Report Analysis</h3>
|
||||
<span className="text-xs text-violet-200 ml-1">· {activeTab.replace("-", " ")}</span>
|
||||
</div>
|
||||
<button onClick={closeExplanation} className="text-violet-200 hover:text-white p-1">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-5 space-y-4">
|
||||
<div className="rounded-xl bg-zinc-900 border border-violet-100 p-4">
|
||||
<p className="text-xs font-semibold text-violet-500 uppercase tracking-wider mb-2">Summary</p>
|
||||
<p className="text-sm text-zinc-300 leading-relaxed">{explanation.summary}</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-zinc-900 border border-violet-100 p-4">
|
||||
<p className="text-xs font-semibold text-violet-500 uppercase tracking-wider mb-2">Key Insights</p>
|
||||
<ul className="space-y-2">
|
||||
{explanation.keyInsights.map((insight, i) => (
|
||||
<li key={i} className="flex items-start gap-2 text-sm text-zinc-300">
|
||||
<span className="text-violet-500 mt-0.5 flex-shrink-0">→</span>
|
||||
<span>{insight}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="rounded-xl bg-zinc-900 border border-violet-100 p-4">
|
||||
<p className="text-xs font-semibold text-green-600 uppercase tracking-wider mb-2">Suggested Actions</p>
|
||||
<ul className="space-y-2">
|
||||
{explanation.suggestedActions.map((action, i) => (
|
||||
<li key={i} className="flex items-start gap-2 text-sm text-zinc-300">
|
||||
<span className="text-green-500 mt-0.5 flex-shrink-0">✓</span>
|
||||
<span>{action}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="rounded-lg bg-amber-900/30 border border-amber-200 p-3 text-xs text-amber-700">
|
||||
⚠️ AI-generated suggestions — review before use. Not a substitute for business judgment.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{explainError && (
|
||||
<div className="rounded-xl bg-red-900/30 border border-red-200 px-4 py-3 text-sm text-red-400">
|
||||
{explainError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useCallback } from "react";
|
||||
import { createStopsBatch } from "@/actions/stops";
|
||||
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 (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
|
||||
<div className="relative w-full max-w-2xl rounded-2xl bg-zinc-900 shadow-xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between rounded-t-2xl border-b border-zinc-800 px-6 py-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-zinc-100">Import Schedule</h2>
|
||||
<p className="mt-0.5 text-sm text-zinc-500">
|
||||
Upload a CSV to bulk-create stops as drafts.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-lg px-3 py-1.5 text-slate-400 hover:bg-zinc-950 hover:text-zinc-400"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="p-6">
|
||||
{step === "idle" && (
|
||||
<div className="space-y-4">
|
||||
{error && (
|
||||
<div className="rounded-xl bg-red-900/30 border border-red-200 px-4 py-3 text-sm text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI toggle */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => setUseAI((v) => !v)}
|
||||
className={`flex items-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium transition-colors ${
|
||||
useAI
|
||||
? "border-purple-300 bg-purple-50 text-purple-700"
|
||||
: "border-zinc-800 text-zinc-400 hover:bg-zinc-800"
|
||||
}`}
|
||||
>
|
||||
<span className="text-base">{useAI ? "◉" : "○"}</span>
|
||||
Use AI for text/PDF parsing
|
||||
</button>
|
||||
<span className="text-xs text-slate-400">
|
||||
{useAI
|
||||
? "AI will parse unstructured text. Requires OPENAI_API_KEY."
|
||||
: "Best results with CSV. Text/PDF uses AI if enabled."}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Drop zone */}
|
||||
<div
|
||||
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className={`cursor-pointer rounded-xl border-2 border-dashed p-10 text-center transition-colors ${
|
||||
dragOver
|
||||
? "border-slate-900 bg-zinc-900"
|
||||
: "border-zinc-600 hover:border-slate-400 hover:bg-zinc-800"
|
||||
}`}
|
||||
>
|
||||
<p className="text-2xl text-slate-400">📄</p>
|
||||
<p className="mt-2 font-medium text-zinc-300">
|
||||
Drop your schedule file here
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-zinc-500">
|
||||
or click to browse — CSV, TXT, JSON
|
||||
</p>
|
||||
<p className="mt-3 text-xs text-slate-400">
|
||||
CSV columns: city, state, location, date, time, address, zip, notes
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".csv,.txt,.json"
|
||||
className="hidden"
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "parsing" && (
|
||||
<div className="flex flex-col items-center gap-4 py-8">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-zinc-600 border-t-slate-900" />
|
||||
<p className="text-zinc-400">Parsing file…</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "review" && (
|
||||
<div className="space-y-4">
|
||||
{error && (
|
||||
<div className="rounded-xl bg-red-900/30 border border-red-200 px-4 py-3 text-sm text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasWarnings && (
|
||||
<div className="rounded-xl bg-amber-900/30 border border-yellow-200 px-4 py-3 text-sm text-yellow-700">
|
||||
<p className="font-medium">Parsing warnings:</p>
|
||||
<ul className="mt-1 list-disc list-inside space-y-0.5">
|
||||
{warnings.slice(0, 5).map((w, i) => (
|
||||
<li key={i}>{w}</li>
|
||||
))}
|
||||
{warnings.length > 5 && <li>…and {warnings.length - 5} more</li>}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-sm text-zinc-400">
|
||||
{parsedStops.length} stop{parsedStops.length !== 1 ? "s" : ""} found — review and edit below before importing.
|
||||
</p>
|
||||
|
||||
{/* Review table */}
|
||||
<div className="max-h-80 overflow-y-auto rounded-xl border border-zinc-800">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="sticky top-0 bg-zinc-900 text-zinc-400">
|
||||
<tr>
|
||||
<th className="px-2 py-2 text-left font-semibold">City</th>
|
||||
<th className="px-2 py-2 text-left font-semibold">State</th>
|
||||
<th className="px-2 py-2 text-left font-semibold">Location</th>
|
||||
<th className="px-2 py-2 text-left font-semibold">Date</th>
|
||||
<th className="px-2 py-2 text-left font-semibold">Time</th>
|
||||
<th className="px-2 py-2 text-left font-semibold">Address</th>
|
||||
<th className="w-8 px-2 py-2" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{parsedStops.map((stop, idx) => (
|
||||
<tr key={idx} className="hover:bg-zinc-800">
|
||||
<td className="px-1 py-1">
|
||||
<input
|
||||
value={stop.city}
|
||||
onChange={(e) => updateStop(idx, "city", e.target.value)}
|
||||
className="w-full rounded border border-transparent bg-transparent px-2 py-1 text-xs outline-none focus:border-slate-400"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-1 py-1">
|
||||
<input
|
||||
value={stop.state}
|
||||
onChange={(e) => updateStop(idx, "state", e.target.value)}
|
||||
className="w-12 rounded border border-transparent bg-transparent px-2 py-1 text-xs outline-none focus:border-slate-400"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-1 py-1">
|
||||
<input
|
||||
value={stop.location}
|
||||
onChange={(e) => updateStop(idx, "location", e.target.value)}
|
||||
className="w-full rounded border border-transparent bg-transparent px-2 py-1 text-xs outline-none focus:border-slate-400"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-1 py-1">
|
||||
<input
|
||||
value={stop.date}
|
||||
onChange={(e) => updateStop(idx, "date", e.target.value)}
|
||||
className="w-24 rounded border border-transparent bg-transparent px-2 py-1 text-xs outline-none focus:border-slate-400"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-1 py-1">
|
||||
<input
|
||||
value={stop.time}
|
||||
onChange={(e) => updateStop(idx, "time", e.target.value)}
|
||||
className="w-20 rounded border border-transparent bg-transparent px-2 py-1 text-xs outline-none focus:border-slate-400"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-1 py-1">
|
||||
<input
|
||||
value={stop.address ?? ""}
|
||||
onChange={(e) => updateStop(idx, "address", e.target.value)}
|
||||
placeholder="—"
|
||||
className="w-full rounded border border-transparent bg-transparent px-2 py-1 text-xs outline-none focus:border-slate-400"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-1 py-1">
|
||||
<button
|
||||
onClick={() => removeStop(idx)}
|
||||
className="rounded px-1 py-0.5 text-red-400 hover:bg-red-900/30 hover:text-red-400"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-zinc-500">
|
||||
{parsedStops.length} draft stop{parsedStops.length !== 1 ? "s" : ""} will be created
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => { setStep("idle"); setParsedStops([]); setWarnings([]); }}
|
||||
className="rounded-xl border border-zinc-600 px-4 py-2 text-sm font-medium text-zinc-300 hover:bg-zinc-800"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleImport}
|
||||
disabled={parsedStops.length === 0}
|
||||
className="rounded-xl bg-slate-900 px-5 py-2 text-sm font-medium text-white disabled:opacity-50 hover:bg-slate-800"
|
||||
>
|
||||
Create {parsedStops.length} Draft Stop{parsedStops.length !== 1 ? "s" : ""}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "importing" && (
|
||||
<div className="flex flex-col items-center gap-4 py-8">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-zinc-600 border-t-slate-900" />
|
||||
<p className="text-zinc-400">Creating draft stops…</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "done" && (
|
||||
<div className="flex flex-col items-center gap-4 py-8 text-center">
|
||||
<p className="text-4xl">✅</p>
|
||||
<p className="text-lg font-bold text-zinc-100">
|
||||
{created} draft stop{created !== 1 ? "s" : ""} created
|
||||
</p>
|
||||
<p className="text-zinc-400">
|
||||
Review them in the stops list and publish when ready.
|
||||
</p>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-xl bg-slate-900 px-6 py-3 font-medium text-white hover:bg-slate-800"
|
||||
>
|
||||
Back to Stops
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "error" && (
|
||||
<div className="flex flex-col items-center gap-4 py-8 text-center">
|
||||
<p className="text-4xl">❌</p>
|
||||
<p className="text-red-400">{error}</p>
|
||||
<button
|
||||
onClick={() => setStep("idle")}
|
||||
className="rounded-xl border border-zinc-600 px-4 py-2 text-sm font-medium text-zinc-300 hover:bg-zinc-800"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,678 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
getTimeTrackingWorkers,
|
||||
getTimeTrackingTasks,
|
||||
createTimeWorker,
|
||||
resetTimeWorkerPin,
|
||||
updateTimeWorker,
|
||||
deleteTimeWorker,
|
||||
createTimeTask,
|
||||
updateTimeTask,
|
||||
deleteTimeTask,
|
||||
getTimeTrackingSettings,
|
||||
updateTimeTrackingSettings,
|
||||
getTimeTrackingNotificationLog,
|
||||
type TimeWorker,
|
||||
type TimeTask,
|
||||
type TimeTrackingSettings,
|
||||
type NotificationLogEntry,
|
||||
} from "@/actions/time-tracking";
|
||||
|
||||
const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
||||
|
||||
type AccordionProps = {
|
||||
title: string;
|
||||
id?: string;
|
||||
description?: string;
|
||||
defaultOpen?: boolean;
|
||||
accentColor?: "emerald" | "violet" | "amber" | "stone";
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
function Accordion({ title, id, description, defaultOpen = false, accentColor = "stone", children }: AccordionProps) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
const accent = {
|
||||
emerald: "bg-emerald-50 text-emerald-700 border-emerald-200",
|
||||
violet: "bg-violet-50 text-violet-700 border-violet-200",
|
||||
amber: "bg-amber-50 text-amber-700 border-amber-200",
|
||||
stone: "bg-stone-50 text-stone-700 border-stone-200",
|
||||
}[accentColor];
|
||||
const borderColor = open ? "border-stone-300" : "border-stone-200";
|
||||
|
||||
return (
|
||||
<div id={id} className={`rounded-xl border ${borderColor} bg-white shadow-sm overflow-hidden transition-all duration-200`}>
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className="w-full flex items-center justify-between px-5 py-4 hover:bg-stone-50 transition-colors"
|
||||
aria-expanded={open}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm font-semibold text-stone-900">{title}</span>
|
||||
{description && (
|
||||
<span className="text-xs text-stone-500 hidden sm:block">{description}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`text-xs font-medium px-2 py-1 rounded-md ${accent}`}>
|
||||
{open ? "Open" : "Closed"}
|
||||
</span>
|
||||
<svg
|
||||
className={`w-4 h-4 text-stone-400 transition-transform duration-200 ${open ? "rotate-180" : ""}`}
|
||||
fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
<div className={`transition-all duration-200 ${open ? "block" : "hidden"}`}>
|
||||
<div className="px-5 pb-5 pt-2">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type Props = {
|
||||
brandId: string;
|
||||
};
|
||||
|
||||
export default function SettingsSections({ brandId }: Props) {
|
||||
const [workers, setWorkers] = useState<TimeWorker[]>([]);
|
||||
const [tasks, setTasks] = useState<TimeTask[]>([]);
|
||||
const [settings, setSettings] = useState<TimeTrackingSettings | null>(null);
|
||||
const [notificationLog, setNotificationLog] = useState<NotificationLogEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Settings form state
|
||||
const [payPeriodStartDay, setPayPeriodStartDay] = useState(0);
|
||||
const [payPeriodLength, setPayPeriodLength] = useState(7);
|
||||
const [dailyOvertimeThreshold, setDailyOvertimeThreshold] = useState(12);
|
||||
const [weeklyOvertimeThreshold, setWeeklyOvertimeThreshold] = useState(56);
|
||||
const [overtimeMultiplier, setOvertimeMultiplier] = useState(1.5);
|
||||
const [overtimeNotifications, setOvertimeNotifications] = useState(true);
|
||||
const [settingsSaving, setSettingsSaving] = useState(false);
|
||||
const [settingsSaved, setSettingsSaved] = useState(false);
|
||||
const [settingsError, setSettingsError] = useState<string | null>(null);
|
||||
|
||||
// Notification form state
|
||||
const [notificationEmails, setNotificationEmails] = useState<string[]>([]);
|
||||
const [notificationSmsNumbers, setNotificationSmsNumbers] = useState<string[]>([]);
|
||||
const [enableDailyAlerts, setEnableDailyAlerts] = useState(true);
|
||||
const [enableWeeklyAlerts, setEnableWeeklyAlerts] = useState(true);
|
||||
const [dailyAlertThreshold, setDailyAlertThreshold] = useState(80);
|
||||
const [weeklyAlertThreshold, setWeeklyAlertThreshold] = useState(80);
|
||||
const [sendEndOfPeriodSummary, setSendEndOfPeriodSummary] = useState(true);
|
||||
const [brandName, setBrandName] = useState("Farm");
|
||||
const [newEmail, setNewEmail] = useState("");
|
||||
const [newSms, setNewSms] = useState("");
|
||||
|
||||
// Worker modal state
|
||||
const [showWorkerModal, setShowWorkerModal] = useState(false);
|
||||
const [editingWorker, setEditingWorker] = useState<TimeWorker | null>(null);
|
||||
const [workerName, setWorkerName] = useState("");
|
||||
const [workerRole, setWorkerRole] = useState("worker");
|
||||
const [workerLang, setWorkerLang] = useState("en");
|
||||
const [workerActive, setWorkerActive] = useState(true);
|
||||
const [resetPinResult, setResetPinResult] = useState<string | null>(null);
|
||||
const [workerError, setWorkerError] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
// Task modal state
|
||||
const [showTaskModal, setShowTaskModal] = useState(false);
|
||||
const [editingTask, setEditingTask] = useState<TimeTask | null>(null);
|
||||
const [taskName, setTaskName] = useState("");
|
||||
const [taskNameEs, setTaskNameEs] = useState("");
|
||||
const [taskUnit, setTaskUnit] = useState("hours");
|
||||
const [taskActive, setTaskActive] = useState(true);
|
||||
const [taskSortOrder, setTaskSortOrder] = useState(0);
|
||||
const [taskError, setTaskError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
const [w, t, s] = await Promise.all([
|
||||
getTimeTrackingWorkers(brandId),
|
||||
getTimeTrackingTasks(brandId, false),
|
||||
getTimeTrackingSettings(brandId),
|
||||
]);
|
||||
setWorkers(w);
|
||||
setTasks(t);
|
||||
if (s) {
|
||||
setSettings(s);
|
||||
setPayPeriodStartDay(s.pay_period_start_day);
|
||||
setPayPeriodLength(s.pay_period_length_days);
|
||||
setDailyOvertimeThreshold(s.daily_overtime_threshold);
|
||||
setWeeklyOvertimeThreshold(s.weekly_overtime_threshold);
|
||||
setOvertimeMultiplier(s.overtime_multiplier);
|
||||
setOvertimeNotifications(s.overtime_notifications);
|
||||
setNotificationEmails(s.notification_emails ?? []);
|
||||
setNotificationSmsNumbers(s.notification_sms_numbers ?? []);
|
||||
setEnableDailyAlerts(s.enable_daily_alerts ?? true);
|
||||
setEnableWeeklyAlerts(s.enable_weekly_alerts ?? true);
|
||||
setDailyAlertThreshold(Math.round((s.daily_alert_threshold ?? 0.80) * 100));
|
||||
setWeeklyAlertThreshold(Math.round((s.weekly_alert_threshold ?? 0.80) * 100));
|
||||
setSendEndOfPeriodSummary(s.send_end_of_period_summary ?? true);
|
||||
setBrandName(s.brand_name ?? "Farm");
|
||||
}
|
||||
const log = await getTimeTrackingNotificationLog(brandId, 50);
|
||||
setNotificationLog(log);
|
||||
setLoading(false);
|
||||
}, [brandId]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const handleSaveNotifications = async () => {
|
||||
setSettingsSaving(true);
|
||||
setSettingsError(null);
|
||||
setSettingsSaved(false);
|
||||
const result = await updateTimeTrackingSettings(brandId, {
|
||||
pay_period_start_day: payPeriodStartDay,
|
||||
pay_period_length_days: payPeriodLength,
|
||||
daily_overtime_threshold: dailyOvertimeThreshold,
|
||||
weekly_overtime_threshold: weeklyOvertimeThreshold,
|
||||
overtime_multiplier: overtimeMultiplier,
|
||||
overtime_notifications: overtimeNotifications,
|
||||
notification_emails: notificationEmails,
|
||||
notification_sms_numbers: notificationSmsNumbers,
|
||||
enable_daily_alerts: enableDailyAlerts,
|
||||
enable_weekly_alerts: enableWeeklyAlerts,
|
||||
daily_alert_threshold: dailyAlertThreshold / 100,
|
||||
weekly_alert_threshold: weeklyAlertThreshold / 100,
|
||||
send_end_of_period_summary: sendEndOfPeriodSummary,
|
||||
brand_name: brandName,
|
||||
});
|
||||
setSettingsSaving(false);
|
||||
if (!result.success) { setSettingsError(result.error ?? "Failed to save"); return; }
|
||||
setSettingsSaved(true);
|
||||
setTimeout(() => setSettingsSaved(false), 3000);
|
||||
load();
|
||||
};
|
||||
|
||||
const addEmail = () => {
|
||||
const email = newEmail.trim();
|
||||
if (email && !notificationEmails.includes(email)) {
|
||||
setNotificationEmails([...notificationEmails, email]);
|
||||
}
|
||||
setNewEmail("");
|
||||
};
|
||||
|
||||
const removeEmail = (email: string) => setNotificationEmails(notificationEmails.filter(e => e !== email));
|
||||
const addSms = () => {
|
||||
const num = newSms.replace(/[^0-9+]/g, "");
|
||||
if (num && !notificationSmsNumbers.includes(num)) {
|
||||
setNotificationSmsNumbers([...notificationSmsNumbers, num]);
|
||||
}
|
||||
setNewSms("");
|
||||
};
|
||||
const removeSms = (num: string) => setNotificationSmsNumbers(notificationSmsNumbers.filter(n => n !== num));
|
||||
|
||||
const openAddWorker = () => {
|
||||
setEditingWorker(null);
|
||||
setWorkerName(""); setWorkerRole("worker"); setWorkerLang("en");
|
||||
setWorkerActive(true); setWorkerError(null); setResetPinResult(null);
|
||||
setShowWorkerModal(true);
|
||||
};
|
||||
|
||||
const openEditWorker = (w: TimeWorker) => {
|
||||
setEditingWorker(w); setWorkerName(w.name); setWorkerRole(w.role);
|
||||
setWorkerLang(w.lang); setWorkerActive(w.active);
|
||||
setWorkerError(null); setResetPinResult(null);
|
||||
setShowWorkerModal(true);
|
||||
};
|
||||
|
||||
const handleSaveWorker = async () => {
|
||||
if (!workerName.trim()) { setWorkerError("Name is required"); return; }
|
||||
setSubmitting(true); setWorkerError(null);
|
||||
if (editingWorker) {
|
||||
const result = await updateTimeWorker(editingWorker.id, workerName.trim(), workerRole, workerLang, workerActive);
|
||||
if (!result.success) { setWorkerError(result.error ?? "Failed"); setSubmitting(false); return; }
|
||||
} else {
|
||||
const result = await createTimeWorker(brandId, workerName.trim(), workerRole, workerLang);
|
||||
if (!result.success) { setWorkerError(result.error ?? "Failed"); setSubmitting(false); return; }
|
||||
}
|
||||
setSubmitting(false); setShowWorkerModal(false); load();
|
||||
};
|
||||
|
||||
const handleResetPin = async (workerId: string) => {
|
||||
const result = await resetTimeWorkerPin(workerId);
|
||||
if (result.success && result.pin) setResetPinResult(result.pin);
|
||||
else setWorkerError(result.error ?? "Failed to reset PIN");
|
||||
};
|
||||
|
||||
const handleDeleteWorker = async (workerId: string) => {
|
||||
if (!confirm("Delete this worker? This cannot be undone.")) return;
|
||||
const result = await deleteTimeWorker(workerId);
|
||||
if (!result.success) { setWorkerError(result.error ?? "Failed"); return; }
|
||||
load();
|
||||
};
|
||||
|
||||
const openAddTask = () => {
|
||||
setEditingTask(null); setTaskName(""); setTaskNameEs("");
|
||||
setTaskUnit("hours"); setTaskActive(true); setTaskSortOrder(0);
|
||||
setTaskError(null); setShowTaskModal(true);
|
||||
};
|
||||
|
||||
const openEditTask = (t: TimeTask) => {
|
||||
setEditingTask(t); setTaskName(t.name); setTaskNameEs(t.name_es ?? "");
|
||||
setTaskUnit(t.unit); setTaskActive(t.active); setTaskSortOrder(t.sort_order);
|
||||
setTaskError(null); setShowTaskModal(true);
|
||||
};
|
||||
|
||||
const handleSaveTask = async () => {
|
||||
if (!taskName.trim()) { setTaskError("Name is required"); return; }
|
||||
setSubmitting(true); setTaskError(null);
|
||||
if (editingTask) {
|
||||
const result = await updateTimeTask(editingTask.id, taskName.trim(), taskNameEs, taskUnit, taskActive, taskSortOrder);
|
||||
if (!result.success) { setTaskError(result.error ?? "Failed"); setSubmitting(false); return; }
|
||||
} else {
|
||||
const result = await createTimeTask(brandId, taskName.trim(), taskNameEs || null, taskUnit, taskSortOrder);
|
||||
if (!result.success) { setTaskError(result.error ?? "Failed"); setSubmitting(false); return; }
|
||||
}
|
||||
setSubmitting(false); setShowTaskModal(false); load();
|
||||
};
|
||||
|
||||
const handleDeleteTask = async (taskId: string) => {
|
||||
if (!confirm("Delete this task?")) return;
|
||||
const result = await deleteTimeTask(taskId);
|
||||
if (!result.success) { setTaskError(result.error ?? "Failed"); return; }
|
||||
load();
|
||||
};
|
||||
|
||||
if (loading) return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="flex items-center gap-3 text-stone-500 text-sm">
|
||||
<svg className="animate-spin w-4 h-4" 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>
|
||||
Loading settings...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
|
||||
{/* ACCORDION 1: General Settings */}
|
||||
<Accordion
|
||||
id="general"
|
||||
title="General Settings"
|
||||
description="Pay period, overtime, alerts"
|
||||
defaultOpen={true}
|
||||
accentColor="stone"
|
||||
>
|
||||
<div className="space-y-5">
|
||||
{/* Pay Period & Overtime */}
|
||||
<div className="bg-stone-50 border border-stone-200 rounded-xl p-5">
|
||||
<h3 className="text-sm font-semibold text-stone-800 mb-4">Pay Period & Overtime</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-2">Work week starts on</label>
|
||||
<select value={payPeriodStartDay} onChange={e => setPayPeriodStartDay(Number(e.target.value))}
|
||||
className="w-full px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 focus:outline-none focus:border-emerald-500 transition-colors">
|
||||
{DAYS.map((d, i) => <option key={i} value={i}>{d}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-2">Pay period length</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<input type="number" min={1} max={31} value={payPeriodLength}
|
||||
onChange={e => setPayPeriodLength(Number(e.target.value))}
|
||||
className="flex-1 px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 focus:outline-none focus:border-emerald-500 transition-colors" />
|
||||
<span className="text-sm text-stone-500">days</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-2">Daily overtime threshold</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<input type="number" min={1} max={24} value={dailyOvertimeThreshold}
|
||||
onChange={e => setDailyOvertimeThreshold(Number(e.target.value))}
|
||||
className="flex-1 px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 focus:outline-none focus:border-emerald-500 transition-colors" />
|
||||
<span className="text-sm text-stone-500">hrs</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-2">Weekly overtime threshold</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<input type="number" min={1} max={80} value={weeklyOvertimeThreshold}
|
||||
onChange={e => setWeeklyOvertimeThreshold(Number(e.target.value))}
|
||||
className="flex-1 px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 focus:outline-none focus:border-emerald-500 transition-colors" />
|
||||
<span className="text-sm text-stone-500">hrs</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Colorado notice */}
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-amber-600 text-base mt-0.5">⚠️</span>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-amber-800">Colorado Overtime Law</p>
|
||||
<p className="text-xs text-amber-700 mt-1">Colorado requires daily overtime (1.5×) after 12 hours in a workday, or weekly overtime after 40 hours in a workweek.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Alert Settings */}
|
||||
<div className="bg-stone-50 border border-stone-200 rounded-xl p-5">
|
||||
<h3 className="text-sm font-semibold text-stone-800 mb-4">Alert Settings</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-stone-700 font-medium">Daily overtime alerts</p>
|
||||
<p className="text-xs text-stone-500">Notify when worker hits daily limit</p>
|
||||
</div>
|
||||
<button onClick={() => setEnableDailyAlerts(!enableDailyAlerts)}
|
||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${enableDailyAlerts ? "bg-emerald-600" : "bg-stone-300"}`}>
|
||||
<span className={`inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform ${enableDailyAlerts ? "translate-x-4" : "translate-x-1"}`} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-stone-700 font-medium">Weekly overtime alerts</p>
|
||||
<p className="text-xs text-stone-500">Notify when worker hits weekly limit</p>
|
||||
</div>
|
||||
<button onClick={() => setEnableWeeklyAlerts(!enableWeeklyAlerts)}
|
||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${enableWeeklyAlerts ? "bg-emerald-600" : "bg-stone-300"}`}>
|
||||
<span className={`inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform ${enableWeeklyAlerts ? "translate-x-4" : "translate-x-1"}`} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notification Recipients */}
|
||||
<div className="bg-stone-50 border border-stone-200 rounded-xl p-5">
|
||||
<h3 className="text-sm font-semibold text-stone-800 mb-4">Notification Recipients</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-2">Email addresses</label>
|
||||
<div className="flex gap-2">
|
||||
<input value={newEmail}
|
||||
onChange={e => setNewEmail(e.target.value)}
|
||||
onKeyDown={e => e.key === "Enter" && (addEmail(), e.preventDefault())}
|
||||
className="flex-1 px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 placeholder:text-stone-400 focus:outline-none focus:border-emerald-500"
|
||||
placeholder="manager@farm.com" />
|
||||
<button onClick={addEmail} className="px-5 py-3 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white font-semibold text-sm">Add</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{notificationEmails.map(e => (
|
||||
<span key={e} className="inline-flex items-center gap-1.5 bg-stone-100 text-stone-700 text-xs px-2.5 py-1 rounded-lg">
|
||||
{e}
|
||||
<button onClick={() => removeEmail(e)} className="text-stone-400 hover:text-red-500 ml-1">×</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-2">SMS numbers</label>
|
||||
<div className="flex gap-2">
|
||||
<input value={newSms}
|
||||
onChange={e => setNewSms(e.target.value)}
|
||||
onKeyDown={e => e.key === "Enter" && (addSms(), e.preventDefault())}
|
||||
className="flex-1 px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 placeholder:text-stone-400 focus:outline-none focus:border-emerald-500"
|
||||
placeholder="+1234567890" />
|
||||
<button onClick={addSms} className="px-5 py-3 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white font-semibold text-sm">Add</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{notificationSmsNumbers.map(n => (
|
||||
<span key={n} className="inline-flex items-center gap-1.5 bg-stone-100 text-stone-700 text-xs px-2.5 py-1 rounded-lg">
|
||||
{n}
|
||||
<button onClick={() => removeSms(n)} className="text-stone-400 hover:text-red-500 ml-1">×</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{settingsError && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-xl py-3 px-4 text-red-700 text-sm">{settingsError}</div>
|
||||
)}
|
||||
{settingsSaved && (
|
||||
<div className="bg-emerald-50 border border-emerald-200 rounded-xl py-3 px-4 text-emerald-700 text-sm">Settings saved.</div>
|
||||
)}
|
||||
<button onClick={handleSaveNotifications} disabled={settingsSaving}
|
||||
className="px-6 py-3 rounded-xl bg-stone-900 hover:bg-stone-800 disabled:opacity-40 font-semibold text-sm text-white transition-all">
|
||||
{settingsSaving ? "Saving..." : "Save Settings"}
|
||||
</button>
|
||||
</div>
|
||||
</Accordion>
|
||||
|
||||
{/* ACCORDION 2: Workers & PINs */}
|
||||
<Accordion
|
||||
id="workers"
|
||||
title="Workers & PINs"
|
||||
description={`${workers.length} worker${workers.length !== 1 ? "s" : ""}`}
|
||||
defaultOpen={true}
|
||||
accentColor="emerald"
|
||||
>
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<p className="text-xs text-stone-500">Manage time tracking workers and PIN codes.</p>
|
||||
<button onClick={openAddWorker}
|
||||
className="text-xs bg-emerald-600 hover:bg-emerald-500 text-white px-3 py-1.5 rounded-lg font-semibold transition-all">+ Add Worker</button>
|
||||
</div>
|
||||
<div className="bg-white border border-stone-200 rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-xs text-stone-500 uppercase tracking-widest border-b border-stone-100">
|
||||
<th className="text-left px-4 py-3 font-medium">Name</th>
|
||||
<th className="text-left px-4 py-3 font-medium">Role</th>
|
||||
<th className="text-left px-4 py-3 font-medium hidden sm:table-cell">Lang</th>
|
||||
<th className="text-left px-4 py-3 font-medium">Status</th>
|
||||
<th className="text-left px-4 py-3 font-medium hidden md:table-cell">Last Used</th>
|
||||
<th className="text-right px-4 py-3 font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{workers.length === 0 ? (
|
||||
<tr><td colSpan={6} className="px-4 py-10 text-center text-stone-400 text-sm">No workers yet — add one to get started.</td></tr>
|
||||
) : workers.map(w => (
|
||||
<tr key={w.id} className="border-t border-stone-100 hover:bg-stone-50 transition-colors">
|
||||
<td className="px-4 py-3.5 text-stone-900 font-medium">{w.name}</td>
|
||||
<td className="px-4 py-3.5 text-stone-500 capitalize text-xs">{w.role}</td>
|
||||
<td className="px-4 py-3.5 text-stone-400 uppercase text-xs font-mono hidden sm:table-cell">{w.lang}</td>
|
||||
<td className="px-4 py-3.5">
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold ${w.active ? "bg-emerald-100 text-emerald-700" : "bg-stone-100 text-stone-500"}`}>
|
||||
{w.active ? "Active" : "Inactive"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3.5 text-stone-400 text-xs hidden md:table-cell">{w.last_used_at ? new Date(w.last_used_at).toLocaleDateString() : "—"}</td>
|
||||
<td className="px-4 py-3.5 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<button onClick={() => openEditWorker(w)} className="text-xs text-stone-500 hover:text-stone-900 px-2 py-1 rounded-lg hover:bg-stone-100 transition-all">Edit</button>
|
||||
<button onClick={() => handleResetPin(w.id)} className="text-xs text-amber-600 hover:text-amber-800 px-2 py-1 rounded-lg hover:bg-amber-50 transition-all">Reset PIN</button>
|
||||
<button onClick={() => handleDeleteWorker(w.id)} className="text-xs text-red-500 hover:text-red-700 px-2 py-1 rounded-lg hover:bg-red-50 transition-all">Delete</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</Accordion>
|
||||
|
||||
{/* ACCORDION 3: Tasks */}
|
||||
<Accordion
|
||||
id="tasks"
|
||||
title="Tasks"
|
||||
description={`${tasks.length} task${tasks.length !== 1 ? "s" : ""}`}
|
||||
defaultOpen={false}
|
||||
accentColor="amber"
|
||||
>
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<p className="text-xs text-stone-500">Define tasks workers can clock into.</p>
|
||||
<button onClick={openAddTask}
|
||||
className="text-xs bg-amber-500 hover:bg-amber-600 text-white px-3 py-1.5 rounded-lg font-semibold transition-all">+ Add Task</button>
|
||||
</div>
|
||||
<div className="bg-white border border-stone-200 rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-xs text-stone-500 uppercase tracking-widest border-b border-stone-100">
|
||||
<th className="text-left px-4 py-3 font-medium">Name (EN)</th>
|
||||
<th className="text-left px-4 py-3 font-medium hidden sm:table-cell">Name (ES)</th>
|
||||
<th className="text-left px-4 py-3 font-medium">Unit</th>
|
||||
<th className="text-left px-4 py-3 font-medium hidden md:table-cell">Sort</th>
|
||||
<th className="text-left px-4 py-3 font-medium">Status</th>
|
||||
<th className="text-right px-4 py-3 font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tasks.length === 0 ? (
|
||||
<tr><td colSpan={6} className="px-4 py-10 text-center text-stone-400 text-sm">No tasks yet — add one to get started.</td></tr>
|
||||
) : tasks.map(t => (
|
||||
<tr key={t.id} className="border-t border-stone-100 hover:bg-stone-50 transition-colors">
|
||||
<td className="px-4 py-3.5 text-stone-900 font-medium">{t.name}</td>
|
||||
<td className="px-4 py-3.5 text-stone-500 text-xs hidden sm:table-cell">{t.name_es ?? "—"}</td>
|
||||
<td className="px-4 py-3.5 text-stone-400 text-xs font-mono">{t.unit}</td>
|
||||
<td className="px-4 py-3.5 text-stone-400 text-xs hidden md:table-cell">{t.sort_order}</td>
|
||||
<td className="px-4 py-3.5">
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold ${t.active ? "bg-emerald-100 text-emerald-700" : "bg-stone-100 text-stone-500"}`}>
|
||||
{t.active ? "Active" : "Inactive"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3.5 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<button onClick={() => openEditTask(t)} className="text-xs text-stone-500 hover:text-stone-900 px-2 py-1 rounded-lg hover:bg-stone-100 transition-all">Edit</button>
|
||||
<button onClick={() => handleDeleteTask(t.id)} className="text-xs text-red-500 hover:text-red-700 px-2 py-1 rounded-lg hover:bg-red-50 transition-all">Delete</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</Accordion>
|
||||
|
||||
{/* Worker Modal */}
|
||||
{showWorkerModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 backdrop-blur-sm">
|
||||
<div className="bg-white border border-stone-200 rounded-2xl w-full max-w-md shadow-xl">
|
||||
<div className="px-6 py-4 border-b border-stone-100 flex items-center justify-between">
|
||||
<h3 className="text-lg font-bold text-stone-900">{editingWorker ? "Edit Worker" : "Add Worker"}</h3>
|
||||
<button onClick={() => setShowWorkerModal(false)} className="text-stone-400 hover:text-stone-600 text-xl leading-none">×</button>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
{workerError && <div className="bg-red-50 border border-red-200 rounded-xl py-3 px-4 text-red-700 text-sm">{workerError}</div>}
|
||||
{resetPinResult && (
|
||||
<div className="bg-emerald-50 border border-emerald-200 rounded-xl py-3 px-4">
|
||||
<p className="text-emerald-700 text-sm font-semibold mb-1">New PIN:</p>
|
||||
<p className="text-2xl font-mono font-bold text-stone-900">{resetPinResult}</p>
|
||||
<p className="text-stone-500 text-xs mt-1">Show this to the worker — it will not be shown again.</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-1.5">Name *</label>
|
||||
<input value={workerName} onChange={e => setWorkerName(e.target.value)}
|
||||
className="w-full px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 placeholder:text-stone-400 focus:outline-none focus:border-emerald-500 transition-colors"
|
||||
placeholder="Worker name" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-1.5">Role</label>
|
||||
<select value={workerRole} onChange={e => setWorkerRole(e.target.value)}
|
||||
className="w-full px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 focus:outline-none focus:border-emerald-500 transition-colors">
|
||||
<option value="worker">Worker</option>
|
||||
<option value="time_admin">Time Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-1.5">Lang</label>
|
||||
<select value={workerLang} onChange={e => setWorkerLang(e.target.value)}
|
||||
className="w-full px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 focus:outline-none focus:border-emerald-500 transition-colors">
|
||||
<option value="en">English</option>
|
||||
<option value="es">Español</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input type="checkbox" id="workerActive" checked={workerActive} onChange={e => setWorkerActive(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-stone-300 bg-white text-emerald-600" />
|
||||
<label htmlFor="workerActive" className="text-sm text-stone-700">Active</label>
|
||||
</div>
|
||||
{editingWorker && (
|
||||
<div className="pt-2 border-t border-stone-100">
|
||||
<button onClick={() => handleResetPin(editingWorker.id)} className="text-xs text-amber-600 hover:text-amber-800 font-semibold underline underline-offset-2">
|
||||
Reset PIN for this worker
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button onClick={() => setShowWorkerModal(false)}
|
||||
className="flex-1 py-3 rounded-xl font-semibold text-sm text-stone-500 hover:text-stone-700 border border-stone-200 hover:border-stone-300 transition-all">
|
||||
Cancel
|
||||
</button>
|
||||
<button onClick={handleSaveWorker} disabled={submitting}
|
||||
className="flex-1 py-3 rounded-xl font-bold text-sm bg-emerald-600 hover:bg-emerald-500 disabled:opacity-40 text-white transition-all">
|
||||
{submitting ? "..." : editingWorker ? "Save" : "Add Worker"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Task Modal */}
|
||||
{showTaskModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 backdrop-blur-sm">
|
||||
<div className="bg-white border border-stone-200 rounded-2xl w-full max-w-md shadow-xl">
|
||||
<div className="px-6 py-4 border-b border-stone-100 flex items-center justify-between">
|
||||
<h3 className="text-lg font-bold text-stone-900">{editingTask ? "Edit Task" : "Add Task"}</h3>
|
||||
<button onClick={() => setShowTaskModal(false)} className="text-stone-400 hover:text-stone-600 text-xl leading-none">×</button>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
{taskError && <div className="bg-red-50 border border-red-200 rounded-xl py-3 px-4 text-red-700 text-sm">{taskError}</div>}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-1.5">Name (EN) *</label>
|
||||
<input value={taskName} onChange={e => setTaskName(e.target.value)}
|
||||
className="w-full px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 placeholder:text-stone-400 focus:outline-none focus:border-emerald-500 transition-colors"
|
||||
placeholder="e.g. Harvesting" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-1.5">Name (ES)</label>
|
||||
<input value={taskNameEs} onChange={e => setTaskNameEs(e.target.value)}
|
||||
className="w-full px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 placeholder:text-stone-400 focus:outline-none focus:border-emerald-500 transition-colors"
|
||||
placeholder="e.g. Cosecha" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-1.5">Unit</label>
|
||||
<select value={taskUnit} onChange={e => setTaskUnit(e.target.value)}
|
||||
className="w-full px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 focus:outline-none focus:border-emerald-500 transition-colors">
|
||||
<option value="hours">Hours</option>
|
||||
<option value="pieces">Pieces</option>
|
||||
<option value="units">Units</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-1.5">Sort Order</label>
|
||||
<input type="number" value={taskSortOrder} onChange={e => setTaskSortOrder(parseInt(e.target.value) || 0)}
|
||||
className="w-full px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 focus:outline-none focus:border-emerald-500 transition-colors" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input type="checkbox" id="taskActive" checked={taskActive} onChange={e => setTaskActive(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-stone-300 bg-white text-emerald-600" />
|
||||
<label htmlFor="taskActive" className="text-sm text-stone-700">Active</label>
|
||||
</div>
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button onClick={() => setShowTaskModal(false)}
|
||||
className="flex-1 py-3 rounded-xl font-semibold text-sm text-stone-500 hover:text-stone-700 border border-stone-200 hover:border-stone-300 transition-all">
|
||||
Cancel
|
||||
</button>
|
||||
<button onClick={handleSaveTask} disabled={submitting}
|
||||
className="flex-1 py-3 rounded-xl font-bold text-sm bg-emerald-600 hover:bg-emerald-500 disabled:opacity-40 text-white transition-all">
|
||||
{submitting ? "..." : editingTask ? "Save" : "Add Task"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,517 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { updateShippingStatus } from "@/actions/shipping";
|
||||
import { getFedExRates, type FedExRate, type FedExServiceType } from "@/actions/shipping/fedex-rates";
|
||||
import { createFedExShipment } from "@/actions/shipping/fedex-labels";
|
||||
|
||||
type OrderItem = {
|
||||
id: string;
|
||||
product_id: string;
|
||||
quantity: number;
|
||||
price: number;
|
||||
fulfillment: string;
|
||||
products: { name: string; is_perishable: boolean } | null;
|
||||
};
|
||||
|
||||
type ShippingOrder = {
|
||||
id: string;
|
||||
customer_name: string;
|
||||
customer_email: string | null;
|
||||
customer_phone: string | null;
|
||||
status: string;
|
||||
subtotal: number;
|
||||
shipping_status: string;
|
||||
tracking_number: string | null;
|
||||
created_at: string;
|
||||
brand_id: string | null;
|
||||
order_items: OrderItem[];
|
||||
};
|
||||
|
||||
type ShippingFulfillmentPanelProps = {
|
||||
initialOrders: ShippingOrder[];
|
||||
canManageOrders: boolean;
|
||||
};
|
||||
|
||||
const SHIPPING_STATUSES = [
|
||||
{ value: "pending", label: "Pending" },
|
||||
{ value: "label_created", label: "Label Created" },
|
||||
{ value: "shipped", label: "Shipped" },
|
||||
{ value: "delivered", label: "Delivered" },
|
||||
{ value: "returned", label: "Returned" },
|
||||
];
|
||||
|
||||
const SERVICE_LABELS: Record<FedExServiceType, string> = {
|
||||
FEDEX_OVERNIGHT: "FedEx Overnight",
|
||||
FEDEX_2_DAY_AIR: "FedEx 2-Day Air",
|
||||
FEDEX_EXPRESS_SAVER: "FedEx Express Saver",
|
||||
FEDEX_GROUND: "FedEx Ground",
|
||||
};
|
||||
|
||||
function shortId(id: string) {
|
||||
return id.slice(0, 8).toUpperCase();
|
||||
}
|
||||
|
||||
function statusBadge(status: string) {
|
||||
const map: Record<string, string> = {
|
||||
pending: "bg-yellow-100 text-yellow-800",
|
||||
label_created: "bg-blue-900/40 text-blue-800",
|
||||
shipped: "bg-indigo-100 text-indigo-800",
|
||||
delivered: "bg-green-900/40 text-green-800",
|
||||
returned: "bg-red-900/40 text-red-800",
|
||||
};
|
||||
const cls = map[status] ?? "bg-zinc-950 text-zinc-300";
|
||||
const label = SHIPPING_STATUSES.find((s) => s.value === status)?.label ?? status;
|
||||
return { cls, label };
|
||||
}
|
||||
|
||||
// ── Rate Modal ────────────────────────────────────────────────────────────────
|
||||
|
||||
function RateModal({
|
||||
order,
|
||||
onClose,
|
||||
}: {
|
||||
order: ShippingOrder;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [rates, setRates] = useState<FedExRate[] | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [creating, setCreating] = useState<FedExServiceType | null>(null);
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<{ labelUrl: string; trackingNumber: string } | null>(null);
|
||||
|
||||
const loadRates = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const res = await getFedExRates(order.id);
|
||||
setLoading(false);
|
||||
if (res.success) {
|
||||
setRates(res.rates);
|
||||
} else {
|
||||
setError(res.error ?? "Failed to load rates");
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectRate = async (rate: FedExRate) => {
|
||||
setCreating(rate.serviceType);
|
||||
setCreateError(null);
|
||||
const res = await createFedExShipment(
|
||||
order.id,
|
||||
rate.serviceType,
|
||||
rate.totalCharge,
|
||||
rate.deliveryDate
|
||||
);
|
||||
setCreating(null);
|
||||
if (res.success) {
|
||||
setResult({ labelUrl: res.labelUrl, trackingNumber: res.trackingNumber });
|
||||
} else {
|
||||
setCreateError(res.error ?? "Failed to create shipment");
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-load on mount
|
||||
if (rates === null && !loading && !error) {
|
||||
loadRates();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="w-full max-w-lg rounded-2xl bg-zinc-900 shadow-2xl max-h-[90vh] overflow-y-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-zinc-800 px-6 py-4 sticky top-0 bg-zinc-900 rounded-t-2xl">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-zinc-100">Shipping Rates</h2>
|
||||
<p className="text-sm text-zinc-500">
|
||||
{order.customer_name} · {shortId(order.id)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-slate-400 hover:text-zinc-400 text-2xl leading-none"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-4">
|
||||
{/* Perishable warning */}
|
||||
{rates !== null && rates[0]?.isPerishableOnly && (
|
||||
<div className="rounded-lg bg-blue-900/30 border border-blue-200 px-4 py-3 text-sm text-blue-700">
|
||||
<strong>Perishable shipment.</strong> Only Overnight and 2-Day Air are available
|
||||
for fresh produce (sweet corn, onions, etc.). Ground and Express Saver are not
|
||||
offered for temperature-sensitive items.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="h-6 w-6 rounded-full border-2 border-blue-600 border-t-transparent animate-spin" />
|
||||
<span className="ml-3 text-zinc-500">Fetching FedEx rates...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg bg-red-900/30 border border-red-200 px-4 py-3 text-sm text-red-400">
|
||||
{error}
|
||||
<button
|
||||
onClick={loadRates}
|
||||
className="ml-2 underline hover:no-underline"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rates !== null && rates.length === 0 && !loading && (
|
||||
<div className="text-center py-8 text-zinc-500">
|
||||
No FedEx rates available for this address. Please verify the shipping address.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rates !== null && rates.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{rates.map((rate) => (
|
||||
<button
|
||||
key={rate.serviceType}
|
||||
onClick={() => handleSelectRate(rate)}
|
||||
disabled={creating !== null}
|
||||
className="w-full flex items-center justify-between rounded-xl border border-zinc-800 px-4 py-3 text-left hover:border-blue-400 hover:bg-blue-900/30 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<div>
|
||||
<div className="font-semibold text-zinc-100">
|
||||
{SERVICE_LABELS[rate.serviceType] ?? rate.serviceType}
|
||||
</div>
|
||||
<div className="text-sm text-zinc-500">
|
||||
{rate.deliveryDateLabel
|
||||
? `Arrives ${rate.deliveryDateLabel}`
|
||||
: rate.deliveryDayOfWeek}
|
||||
</div>
|
||||
{rate.isPerishableOnly && (
|
||||
<span className="mt-1 inline-block rounded-full bg-blue-900/40 text-blue-700 px-2 py-0.5 text-xs font-medium">
|
||||
Required for perishable
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="font-bold text-zinc-100">
|
||||
${(rate.totalCharge / 100).toFixed(2)}
|
||||
</div>
|
||||
{creating === rate.serviceType && (
|
||||
<div className="h-4 w-4 rounded-full border-2 border-blue-600 border-t-transparent animate-spin mt-1 mx-auto" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{createError && (
|
||||
<div className="rounded-lg bg-red-900/30 border border-red-200 px-4 py-3 text-sm text-red-400">
|
||||
{createError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<div className="rounded-xl bg-emerald-50 border border-emerald-200 px-4 py-4 space-y-3">
|
||||
<div className="flex items-center gap-2 text-emerald-700 font-semibold">
|
||||
<span className="text-lg">✓</span> Label Created
|
||||
</div>
|
||||
<div className="text-sm text-emerald-600">
|
||||
<div>Tracking: <span className="font-mono font-medium">{result.trackingNumber}</span></div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{result.labelUrl && (
|
||||
<a
|
||||
href={result.labelUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 rounded-lg bg-emerald-600 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-700"
|
||||
>
|
||||
Download Label
|
||||
</a>
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="inline-flex items-center rounded-lg border border-zinc-600 px-4 py-2 text-sm font-medium text-zinc-300 hover:bg-zinc-800"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main Panel ────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function ShippingFulfillmentPanel({
|
||||
initialOrders,
|
||||
canManageOrders,
|
||||
}: ShippingFulfillmentPanelProps) {
|
||||
const [orders, setOrders] = useState<ShippingOrder[]>(initialOrders);
|
||||
const [search, setSearch] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("");
|
||||
const [updating, setUpdating] = useState<string | null>(null);
|
||||
const [trackingInputs, setTrackingInputs] = useState<Record<string, string>>({});
|
||||
const [rateModalOrder, setRateModalOrder] = useState<ShippingOrder | null>(null);
|
||||
|
||||
const filtered = orders.filter((o) => {
|
||||
const matchesSearch =
|
||||
!search ||
|
||||
o.customer_name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
(o.customer_phone ?? "").includes(search) ||
|
||||
o.id.includes(search.toUpperCase().slice(0, 8));
|
||||
|
||||
const matchesStatus = !statusFilter || o.shipping_status === statusFilter;
|
||||
return matchesSearch && matchesStatus;
|
||||
});
|
||||
|
||||
const counts = SHIPPING_STATUSES.reduce(
|
||||
(acc, s) => {
|
||||
acc[s.value] = orders.filter((o) => o.shipping_status === s.value).length;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>
|
||||
);
|
||||
|
||||
async function handleStatusChange(orderId: string, newStatus: string) {
|
||||
if (!canManageOrders) return;
|
||||
setUpdating(orderId);
|
||||
|
||||
const trackingNumber =
|
||||
newStatus === "shipped" ? trackingInputs[orderId] ?? null : null;
|
||||
|
||||
const result = await updateShippingStatus(orderId, newStatus, trackingNumber ?? undefined);
|
||||
|
||||
if (result.success) {
|
||||
setOrders((prev) =>
|
||||
prev.map((o) =>
|
||||
o.id === orderId
|
||||
? { ...o, shipping_status: newStatus, tracking_number: trackingNumber ?? o.tracking_number }
|
||||
: o
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
setUpdating(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-950">
|
||||
{/* Header */}
|
||||
<div className="bg-slate-900 px-4 py-5">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Shipping Fulfillment</h1>
|
||||
<p className="mt-1 text-sm text-slate-400">
|
||||
{orders.length} shipping order{orders.length !== 1 ? "s" : ""}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Link
|
||||
href="/admin/orders"
|
||||
className="rounded-lg bg-slate-700 px-4 py-2 text-sm font-medium text-white hover:bg-slate-600"
|
||||
>
|
||||
All Orders
|
||||
</Link>
|
||||
<Link
|
||||
href="/admin/pickup"
|
||||
className="rounded-lg bg-slate-700 px-4 py-2 text-sm font-medium text-white hover:bg-slate-600"
|
||||
>
|
||||
Pickup
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto max-w-6xl px-4 py-4 space-y-4">
|
||||
{/* Status filter pills */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
onClick={() => setStatusFilter("")}
|
||||
className={`rounded-full px-3 py-1.5 text-sm font-medium ${
|
||||
!statusFilter ? "bg-slate-900 text-white" : "bg-zinc-900 text-zinc-400"
|
||||
}`}
|
||||
>
|
||||
All ({orders.length})
|
||||
</button>
|
||||
{SHIPPING_STATUSES.map((s) => (
|
||||
<button
|
||||
key={s.value}
|
||||
onClick={() => setStatusFilter(s.value)}
|
||||
className={`rounded-full px-3 py-1.5 text-sm font-medium ${
|
||||
statusFilter === s.value ? "bg-slate-900 text-white" : "bg-zinc-900 text-zinc-400"
|
||||
}`}
|
||||
>
|
||||
{s.label} ({counts[s.value] ?? 0})
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search name, phone, or order #..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-4 text-base outline-none focus:border-slate-900"
|
||||
/>
|
||||
|
||||
{/* Orders */}
|
||||
<div className="space-y-3">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="rounded-xl bg-zinc-900 py-12 text-center text-slate-400">
|
||||
No shipping orders
|
||||
</div>
|
||||
) : (
|
||||
filtered.map((order) => {
|
||||
const { cls, label } = statusBadge(order.shipping_status);
|
||||
const hasShipItems = order.order_items.some((i) => i.fulfillment === "ship");
|
||||
const hasPerishableItems = order.order_items.some(
|
||||
(i) => i.fulfillment === "ship" && i.products?.is_perishable
|
||||
);
|
||||
const isPending = order.shipping_status === "pending" || order.shipping_status === "label_created";
|
||||
|
||||
return (
|
||||
<div key={order.id} className="rounded-2xl bg-zinc-900 p-5 shadow-black/20 ring-1 ring-zinc-700">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xl font-bold text-zinc-100">{order.customer_name}</p>
|
||||
{order.customer_phone && (
|
||||
<p className="mt-1 text-base text-zinc-400">{order.customer_phone}</p>
|
||||
)}
|
||||
<p className="mt-1 font-mono text-xs text-slate-400 uppercase">{shortId(order.id)}</p>
|
||||
</div>
|
||||
<span className={`shrink-0 rounded-full px-3 py-1 text-xs font-bold ${cls}`}>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Perishable indicator */}
|
||||
{hasPerishableItems && isPending && (
|
||||
<div className="mt-3 rounded-lg bg-blue-900/30 border border-blue-200 px-3 py-2 text-xs text-blue-700 font-medium">
|
||||
🌽 Perishable — Overnight or 2-Day Air only
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tracking number */}
|
||||
{order.tracking_number && (
|
||||
<div className="mt-3 rounded-lg bg-slate-50 px-3 py-2 text-sm flex items-center justify-between">
|
||||
<div>
|
||||
<span className="text-zinc-500">Tracking: </span>
|
||||
<span className="font-mono font-medium text-zinc-300">{order.tracking_number}</span>
|
||||
</div>
|
||||
{order.shipping_status === "label_created" && (
|
||||
<button
|
||||
onClick={() => handleStatusChange(order.id, "shipped")}
|
||||
disabled={updating === order.id}
|
||||
className="text-xs rounded-lg bg-indigo-600 px-3 py-1.5 font-medium text-white hover:bg-indigo-700 disabled:opacity-50"
|
||||
>
|
||||
{updating === order.id ? "..." : "Mark Shipped"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Items */}
|
||||
{order.order_items.filter((i) => i.fulfillment === "ship").length > 0 && (
|
||||
<div className="mt-4 rounded-lg bg-slate-50 p-3">
|
||||
<ul className="space-y-1">
|
||||
{order.order_items
|
||||
.filter((i) => i.fulfillment === "ship")
|
||||
.map((item) => (
|
||||
<li key={item.id} className="flex items-center justify-between text-sm">
|
||||
<span className="text-zinc-300">
|
||||
{item.quantity > 1 && (
|
||||
<span className="font-semibold text-zinc-100">{item.quantity}× </span>
|
||||
)}
|
||||
{item.products?.name ?? "Unknown Product"}
|
||||
{item.products?.is_perishable && (
|
||||
<span className="ml-1.5 text-xs text-blue-400">🌽 perishable</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-zinc-500">
|
||||
${(Number(item.price) * item.quantity).toFixed(2)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="mt-2 border-t border-zinc-800 pt-2 flex justify-between">
|
||||
<span className="text-sm font-medium text-zinc-400">Subtotal</span>
|
||||
<span className="text-sm font-bold text-zinc-100">${Number(order.subtotal).toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
{canManageOrders && hasShipItems && isPending && (
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{/* Get Rates / Create Label */}
|
||||
{order.shipping_status === "pending" && (
|
||||
<button
|
||||
onClick={() => setRateModalOrder(order)}
|
||||
className="rounded-xl bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
|
||||
>
|
||||
Get FedEx Rates
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Manual tracking input (fallback) */}
|
||||
{(order.shipping_status === "pending" ||
|
||||
order.shipping_status === "label_created") && (
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Enter tracking manually"
|
||||
value={trackingInputs[order.id] ?? ""}
|
||||
onChange={(e) =>
|
||||
setTrackingInputs((prev) => ({ ...prev, [order.id]: e.target.value }))
|
||||
}
|
||||
className="flex-1 min-w-[160px] rounded-lg border border-zinc-600 px-3 py-2 text-sm outline-none focus:border-slate-900"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Status transitions */}
|
||||
{SHIPPING_STATUSES.filter(
|
||||
(s) =>
|
||||
s.value !== order.shipping_status &&
|
||||
s.value !== "label_created" // handled by create label flow
|
||||
).map((s) => (
|
||||
<button
|
||||
key={s.value}
|
||||
onClick={() => handleStatusChange(order.id, s.value)}
|
||||
disabled={updating === order.id}
|
||||
className="rounded-xl border border-zinc-600 px-4 py-2 text-sm font-medium text-zinc-300 hover:bg-zinc-800 disabled:opacity-50"
|
||||
>
|
||||
{updating === order.id ? "..." : `Mark ${s.label}`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!canManageOrders && (
|
||||
<div className="mt-4 rounded-xl bg-zinc-950 px-4 py-3 text-center text-sm text-zinc-500">
|
||||
No order management permission
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rate Modal */}
|
||||
{rateModalOrder && (
|
||||
<RateModal order={rateModalOrder} onClose={() => setRateModalOrder(null)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
getShippingSettings,
|
||||
saveShippingSettings,
|
||||
testFedExConnection,
|
||||
type ShippingSettings,
|
||||
} from "@/actions/shipping/settings";
|
||||
|
||||
const SERVICE_OPTIONS = [
|
||||
{ value: "FEDEX_OVERNIGHT", label: "FedEx Overnight" },
|
||||
{ value: "FEDEX_2_DAY_AIR", label: "FedEx 2-Day Air" },
|
||||
{ value: "FEDEX_EXPRESS_SAVER", label: "FedEx Express Saver" },
|
||||
{ value: "FEDEX_GROUND", label: "FedEx Ground" },
|
||||
];
|
||||
|
||||
type Props = {
|
||||
settings: ShippingSettings | null;
|
||||
brandId: string;
|
||||
brands?: { id: string; name: string }[];
|
||||
isPlatformAdmin?: boolean;
|
||||
};
|
||||
|
||||
export default function ShippingSettingsForm({
|
||||
settings,
|
||||
brandId: initialBrandId,
|
||||
brands = [],
|
||||
isPlatformAdmin = false,
|
||||
}: Props) {
|
||||
const [activeBrandId, setActiveBrandId] = useState(initialBrandId);
|
||||
const [fedexAccountNumber, setFedexAccountNumber] = useState(settings?.fedex_account_number ?? "");
|
||||
const [fedexApiKey, setFedexApiKey] = useState(settings?.fedex_api_key ?? "");
|
||||
const [fedexApiSecret, setFedexApiSecret] = useState(settings?.fedex_api_secret ?? "");
|
||||
const [fedexUseProduction, setFedexUseProduction] = useState(settings?.fedex_use_production ?? false);
|
||||
const [defaultServiceType, setDefaultServiceType] = useState(settings?.default_service_type ?? "FEDEX_GROUND");
|
||||
const [refrigeratedHandlingNotes, setRefrigeratedHandlingNotes] = useState(
|
||||
settings?.refrigerated_handling_notes ?? "Keep refrigerated. Do not freeze. Handle with care — contains fresh sweet corn and/or onions."
|
||||
);
|
||||
const [fragileHandlingNotes, setFragileHandlingNotes] = useState(settings?.fragile_handling_notes ?? "");
|
||||
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
|
||||
const [showSecret, setShowSecret] = useState(false);
|
||||
|
||||
const isConfigured = !!settings?.fedex_api_key && !!settings?.fedex_api_secret && !!settings?.fedex_account_number;
|
||||
|
||||
// Reload settings when brand changes
|
||||
useEffect(() => {
|
||||
if (!isPlatformAdmin) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
getShippingSettings(activeBrandId).then((result) => {
|
||||
setLoading(false);
|
||||
if (result.success && result.settings) {
|
||||
const s = result.settings;
|
||||
setFedexAccountNumber(s.fedex_account_number ?? "");
|
||||
setFedexApiKey(s.fedex_api_key ?? "");
|
||||
setFedexApiSecret(s.fedex_api_secret ?? "");
|
||||
setFedexUseProduction(s.fedex_use_production);
|
||||
setDefaultServiceType(s.default_service_type);
|
||||
setRefrigeratedHandlingNotes(s.refrigerated_handling_notes ?? "");
|
||||
setFragileHandlingNotes(s.fragile_handling_notes ?? "");
|
||||
} else {
|
||||
// Reset form for new brand
|
||||
setFedexAccountNumber("");
|
||||
setFedexApiKey("");
|
||||
setFedexApiSecret("");
|
||||
setFedexUseProduction(false);
|
||||
setDefaultServiceType("FEDEX_GROUND");
|
||||
setRefrigeratedHandlingNotes("Keep refrigerated. Do not freeze. Handle with care — contains fresh sweet corn and/or onions.");
|
||||
setFragileHandlingNotes("");
|
||||
}
|
||||
});
|
||||
}, [activeBrandId, isPlatformAdmin]);
|
||||
|
||||
async function handleSave(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
setSaved(false);
|
||||
setTestResult(null);
|
||||
|
||||
const result = await saveShippingSettings({
|
||||
brandId: activeBrandId,
|
||||
fedexAccountNumber: fedexAccountNumber || undefined,
|
||||
fedexApiKey: fedexApiKey || undefined,
|
||||
fedexApiSecret: fedexApiSecret || undefined,
|
||||
fedexUseProduction,
|
||||
defaultServiceType,
|
||||
refrigeratedHandlingNotes: refrigeratedHandlingNotes || undefined,
|
||||
fragileHandlingNotes: fragileHandlingNotes || undefined,
|
||||
});
|
||||
|
||||
setSaving(false);
|
||||
if (!result.success) {
|
||||
setError(result.error ?? "Failed to save");
|
||||
} else {
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 3000);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTestConnection() {
|
||||
if (!fedexApiKey || !fedexApiSecret) {
|
||||
setTestResult({ success: false, message: "Enter API Key and API Secret before testing." });
|
||||
return;
|
||||
}
|
||||
setTesting(true);
|
||||
setTestResult(null);
|
||||
const result = await testFedExConnection(fedexApiKey, fedexApiSecret, fedexUseProduction);
|
||||
setTesting(false);
|
||||
setTestResult({ success: result.success, message: result.success ? result.message : result.error ?? "Connection failed" });
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSave} className="space-y-8">
|
||||
{/* Platform admin brand picker */}
|
||||
{isPlatformAdmin && brands.length > 0 && (
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-zinc-300">Brand</label>
|
||||
<select
|
||||
value={activeBrandId}
|
||||
onChange={(e) => setActiveBrandId(e.target.value)}
|
||||
className="w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-base text-zinc-100 outline-none focus:border-zinc-400"
|
||||
>
|
||||
{brands.map((b) => (
|
||||
<option key={b.id} value={b.id}>{b.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Connection status banner */}
|
||||
<div className={`flex items-center gap-3 rounded-xl px-4 py-3 text-sm font-medium ${
|
||||
isConfigured
|
||||
? "bg-emerald-900/30 border border-emerald-700 text-emerald-300"
|
||||
: "bg-amber-900/30 border border-amber-700 text-amber-300"
|
||||
}`}>
|
||||
<span className={`h-2.5 w-2.5 rounded-full ${isConfigured ? "bg-emerald-500" : "bg-amber-900/300"}`} />
|
||||
{isConfigured
|
||||
? `FedEx Connected — ${settings?.fedex_use_production ? "Production" : "Sandbox"} mode`
|
||||
: "FedEx Not Configured — enter credentials below to enable shipping rates and label generation"}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-xl bg-red-900/50 border border-red-700 p-4 text-sm text-red-200">{error}</div>
|
||||
)}
|
||||
{saved && (
|
||||
<div className="rounded-xl bg-green-900/50 border border-green-700 p-4 text-sm text-green-200">
|
||||
Shipping settings saved.
|
||||
</div>
|
||||
)}
|
||||
{testResult && (
|
||||
<div className={`rounded-xl p-4 text-sm border ${testResult.success ? "bg-green-900/50 border-green-700 text-green-200" : "bg-red-900/50 border-red-700 text-red-200"}`}>
|
||||
{testResult.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* FedEx credentials */}
|
||||
<div className="space-y-5 rounded-xl border border-blue-400 bg-blue-900/20 p-5">
|
||||
<div>
|
||||
<h3 className="font-semibold text-blue-300">FedEx API Credentials</h3>
|
||||
<p className="mt-1 text-sm text-blue-400">
|
||||
Used for shipping fresh sweet corn and onions. Get your credentials from{" "}
|
||||
<a
|
||||
href="https://developer.fedex.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline hover:no-underline"
|
||||
>
|
||||
developer.fedex.com
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300">
|
||||
FedEx Account Number <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={fedexAccountNumber}
|
||||
onChange={(e) => setFedexAccountNumber(e.target.value)}
|
||||
placeholder="000000000000 (12 digits)"
|
||||
maxLength={12}
|
||||
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-sm font-mono text-zinc-100 outline-none focus:border-zinc-400"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300">
|
||||
API Key <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={fedexApiKey}
|
||||
onChange={(e) => setFedexApiKey(e.target.value)}
|
||||
placeholder="Your FedEx API key"
|
||||
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-sm text-zinc-100 outline-none focus:border-zinc-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300">
|
||||
API Secret <span className="text-red-500">*</span>
|
||||
<span className="ml-2 text-slate-400 font-normal">(password field)</span>
|
||||
</label>
|
||||
<div className="relative mt-1">
|
||||
<input
|
||||
type={showSecret ? "text" : "password"}
|
||||
value={fedexApiSecret}
|
||||
onChange={(e) => setFedexApiSecret(e.target.value)}
|
||||
placeholder="Your FedEx API secret"
|
||||
className="w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 pr-12 text-sm text-zinc-100 outline-none focus:border-zinc-400"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSecret(!showSecret)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-zinc-400 text-xs"
|
||||
>
|
||||
{showSecret ? "Hide" : "Show"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Production toggle */}
|
||||
<div className="flex items-center gap-3 rounded-lg border border-zinc-700 bg-zinc-800 px-4 py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFedexUseProduction(!fedexUseProduction)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
fedexUseProduction ? "bg-blue-600" : "bg-zinc-600"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-zinc-900 transition-transform ${
|
||||
fedexUseProduction ? "translate-x-6" : "translate-x-1"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-zinc-200">Use Production Mode</span>
|
||||
<p className="text-xs text-zinc-400">
|
||||
{fedexUseProduction
|
||||
? "Live rates, real labels. Sandbox is currently active."
|
||||
: "Sandbox/test mode. Use for testing before going live."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Test connection */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTestConnection}
|
||||
disabled={testing || !fedexApiKey || !fedexApiSecret}
|
||||
className="rounded-xl border border-blue-400 bg-blue-600 px-5 py-2.5 text-sm font-semibold text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{testing ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="h-4 w-4 rounded-full border-2 border-blue-600 border-t-transparent animate-spin" />
|
||||
Testing...
|
||||
</span>
|
||||
) : (
|
||||
"Test Connection"
|
||||
)}
|
||||
</button>
|
||||
{testResult?.success && (
|
||||
<span className="text-sm text-green-400 font-medium">✓ Connection verified</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Default shipping options */}
|
||||
<div className="space-y-5 rounded-xl border border-zinc-700 bg-zinc-900 p-5">
|
||||
<div>
|
||||
<h3 className="font-semibold text-zinc-200">Default Shipping Service</h3>
|
||||
<p className="mt-1 text-sm text-zinc-400">
|
||||
Applied automatically when creating shipments. Perishable orders always require Overnight or 2-Day Air regardless of this setting.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-zinc-300">
|
||||
Default for non-perishable orders
|
||||
</label>
|
||||
<select
|
||||
value={defaultServiceType}
|
||||
onChange={(e) => setDefaultServiceType(e.target.value)}
|
||||
className="w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-sm text-zinc-100 outline-none focus:border-zinc-400"
|
||||
>
|
||||
{SERVICE_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Handling notes */}
|
||||
<div className="space-y-5 rounded-xl border border-zinc-700 bg-zinc-900 p-5">
|
||||
<div>
|
||||
<h3 className="font-semibold text-zinc-200">Handling Instructions</h3>
|
||||
<p className="mt-1 text-sm text-zinc-400">
|
||||
These notes are attached to shipments containing perishable or fragile items (sweet corn, onions, etc.). Appear on the carrier label and in the warehouse.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-zinc-300">
|
||||
🌽 Refrigerated / Perishable Notes
|
||||
</label>
|
||||
<textarea
|
||||
value={refrigeratedHandlingNotes}
|
||||
onChange={(e) => setRefrigeratedHandlingNotes(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-sm text-zinc-100 outline-none focus:border-zinc-400"
|
||||
placeholder="e.g. Keep refrigerated. Do not freeze. Contains fresh sweet corn and/or onions."
|
||||
/>
|
||||
<p className="mt-1 text-xs text-zinc-500">
|
||||
Applied to all shipments with perishable items (is_perishable = true).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-zinc-300">
|
||||
📦 Fragile Items Notes
|
||||
</label>
|
||||
<textarea
|
||||
value={fragileHandlingNotes}
|
||||
onChange={(e) => setFragileHandlingNotes(e.target.value)}
|
||||
rows={2}
|
||||
className="w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-sm text-zinc-100 outline-none focus:border-zinc-400"
|
||||
placeholder="e.g. Fragile — handle with care. Do not stack heavy items on top."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-3 text-zinc-500">
|
||||
<div className="h-5 w-5 rounded-full border-2 border-zinc-500 border-t-transparent animate-spin" />
|
||||
Loading settings...
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="rounded-xl bg-blue-600 px-6 py-3 text-sm font-bold text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{saving ? "Saving..." : "Save Shipping Settings"}
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { getSyncLog, syncSquareNow, type SyncLogEntry } from "@/actions/square-sync-ui";
|
||||
import { getPaymentSettings } from "@/actions/payments";
|
||||
|
||||
type Props = {
|
||||
brandId: string;
|
||||
};
|
||||
|
||||
export default function SquareSyncWidget({ brandId }: Props) {
|
||||
const [settings, setSettings] = useState<{
|
||||
provider: string | null;
|
||||
square_access_token: string | null;
|
||||
square_sync_enabled: boolean;
|
||||
square_inventory_mode: string;
|
||||
square_last_sync_at: string | null;
|
||||
square_last_sync_error: string | null;
|
||||
} | null>(null);
|
||||
const [logs, setLogs] = useState<SyncLogEntry[]>([]);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [syncMsg, setSyncMsg] = useState<{ kind: "success" | "error"; text: string } | null>(null);
|
||||
const [queueCount, setQueueCount] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
getPaymentSettings(brandId).then((r) => {
|
||||
if (r.success && r.settings) setSettings(r.settings as any);
|
||||
});
|
||||
getSyncLog(brandId).then((r) => {
|
||||
if (r.success) setLogs(r.logs.slice(0, 5));
|
||||
});
|
||||
// Poll pending queue count every 30s
|
||||
const interval = setInterval(() => checkQueueCount(), 30000);
|
||||
checkQueueCount();
|
||||
return () => clearInterval(interval);
|
||||
}, [brandId]);
|
||||
|
||||
async function checkQueueCount() {
|
||||
const { supabase } = await import("@/lib/supabase");
|
||||
const { count } = await supabase
|
||||
.from("square_sync_queue")
|
||||
.select("*", { count: "exact", head: true })
|
||||
.eq("brand_id", brandId)
|
||||
.in("status", ["pending"]);
|
||||
setQueueCount(count ?? 0);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!syncMsg) return;
|
||||
const timer = setTimeout(() => setSyncMsg(null), 4000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [syncMsg]);
|
||||
|
||||
async function handleSyncNow(type: "products" | "orders" | "all") {
|
||||
setSyncing(true);
|
||||
setSyncMsg(null);
|
||||
const result = await syncSquareNow(brandId, type);
|
||||
setSyncMsg({
|
||||
kind: result.success ? "success" : "error",
|
||||
text: result.success
|
||||
? `Sync complete — ${result.synced} item(s) synced.`
|
||||
: `Sync failed: ${result.errors[0] ?? "Unknown error"}`,
|
||||
});
|
||||
setSyncing(false);
|
||||
getSyncLog(brandId).then((r) => {
|
||||
if (r.success) setLogs(r.logs.slice(0, 5));
|
||||
});
|
||||
getPaymentSettings(brandId).then((r) => {
|
||||
if (r.success && r.settings) setSettings(r.settings as any);
|
||||
});
|
||||
}
|
||||
|
||||
const lastSyncAt = settings?.square_last_sync_at
|
||||
? timeAgo(settings.square_last_sync_at)
|
||||
: "Never";
|
||||
const lastError = settings?.square_last_sync_error;
|
||||
const hasToken = !!(settings?.square_access_token);
|
||||
const autoSyncEnabled = settings?.square_sync_enabled && hasToken;
|
||||
|
||||
const subheading = hasToken
|
||||
? autoSyncEnabled
|
||||
? `Wholesale products · ${lastSyncAt}`
|
||||
: "Connected — auto-sync disabled"
|
||||
: "Not connected";
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl bg-zinc-900 p-6 shadow-black/20 ring-1 ring-zinc-700">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-green-900/40">
|
||||
<svg className="h-5 w-5 text-green-400" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold text-zinc-100">Square Inventory</h3>
|
||||
{autoSyncEnabled && (
|
||||
<span className="rounded-full bg-green-900/40 px-2 py-0.5 text-xs font-medium text-green-400">
|
||||
Auto-sync
|
||||
</span>
|
||||
)}
|
||||
{!autoSyncEnabled && hasToken && (
|
||||
<span className="rounded-full bg-zinc-950 px-2 py-0.5 text-xs font-medium text-zinc-500">
|
||||
Sync off
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-zinc-500">
|
||||
{subheading}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{lastError && (
|
||||
<span className="rounded-full bg-red-900/40 px-2.5 py-0.5 text-xs font-medium text-red-400">
|
||||
Sync Error
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{lastError && (
|
||||
<div className="mb-3 rounded-lg border border-red-200 bg-red-900/30 px-3 py-2 text-xs text-red-400">
|
||||
{lastError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{syncMsg && (
|
||||
<div className={`mb-3 rounded-lg border px-3 py-2 text-sm ${
|
||||
syncMsg.kind === "success"
|
||||
? "border-green-200 bg-green-900/30 text-green-400"
|
||||
: "border-red-200 bg-red-900/30 text-red-400"
|
||||
}`}>
|
||||
{syncMsg.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasToken ? (
|
||||
<>
|
||||
<div className="mb-3 flex flex-wrap gap-2">
|
||||
<button
|
||||
onClick={() => handleSyncNow("products")}
|
||||
disabled={syncing}
|
||||
className="rounded-lg border border-zinc-600 bg-zinc-900 px-3 py-1.5 text-xs font-medium text-zinc-300 hover:bg-zinc-800 disabled:opacity-50"
|
||||
>
|
||||
{syncing ? "..." : "Sync Products"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleSyncNow("orders")}
|
||||
disabled={syncing}
|
||||
className="rounded-lg border border-zinc-600 bg-zinc-900 px-3 py-1.5 text-xs font-medium text-zinc-300 hover:bg-zinc-800 disabled:opacity-50"
|
||||
>
|
||||
{syncing ? "..." : "Sync Orders"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleSyncNow("all")}
|
||||
disabled={syncing}
|
||||
className="rounded-lg bg-green-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-green-700 disabled:opacity-50"
|
||||
>
|
||||
{syncing ? "..." : "Sync All"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!autoSyncEnabled && hasToken && (
|
||||
<div className="mt-2 rounded-lg border border-amber-200 bg-amber-900/30 px-3 py-2 text-xs text-amber-700">
|
||||
Square sync is turned off. Wholesale products will not be pushed to Square automatically.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{logs.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-slate-400">
|
||||
Recent activity
|
||||
</p>
|
||||
{logs.slice(0, 4).map((log) => (
|
||||
<div
|
||||
key={log.id}
|
||||
className={`flex items-center justify-between rounded-lg border px-3 py-2 text-xs ${
|
||||
log.status === "success"
|
||||
? "border-green-200 bg-green-900/30 text-green-400"
|
||||
: "border-red-200 bg-red-900/30 text-red-400"
|
||||
}`}
|
||||
>
|
||||
<span className="truncate">
|
||||
[{log.direction ?? "—"}] {log.event_type}
|
||||
</span>
|
||||
<span className="ml-2 text-slate-400">
|
||||
{timeAgo(log.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-3 flex gap-2">
|
||||
<Link
|
||||
href="/admin/settings/square-sync"
|
||||
className="rounded-lg border border-zinc-600 bg-zinc-900 px-3 py-1.5 text-xs font-medium text-zinc-400 hover:bg-zinc-800"
|
||||
>
|
||||
View Settings
|
||||
</Link>
|
||||
<Link
|
||||
href="/admin/orders?square=1"
|
||||
className="rounded-lg border border-zinc-600 bg-zinc-900 px-3 py-1.5 text-xs font-medium text-zinc-400 hover:bg-zinc-800"
|
||||
>
|
||||
Square Orders
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<Link
|
||||
href="/admin/settings/payments"
|
||||
className="inline-block rounded-lg bg-green-600 px-4 py-2 text-sm font-semibold text-white hover:bg-green-700"
|
||||
>
|
||||
Connect Square
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function timeAgo(iso: string): string {
|
||||
const diff = Date.now() - new Date(iso).getTime();
|
||||
const mins = Math.floor(diff / 60000);
|
||||
if (mins < 1) return "just now";
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hrs = Math.floor(mins / 60);
|
||||
if (hrs < 24) return `${hrs}h ago`;
|
||||
return `${Math.floor(hrs / 24)}d ago`;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import SquareSyncWidget from "@/components/admin/SquareSyncWidget";
|
||||
|
||||
type Props = { brandId: string };
|
||||
|
||||
export default function SquareSyncWidgetWrapper({ brandId }: Props) {
|
||||
return <SquareSyncWidget brandId={brandId} />;
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { updateStop } from "@/actions/stops/update-stop";
|
||||
|
||||
type Brand = {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
};
|
||||
|
||||
type StopEditFormProps = {
|
||||
stop: {
|
||||
id: string;
|
||||
city: string;
|
||||
state: string;
|
||||
date: string;
|
||||
time: string;
|
||||
location: string;
|
||||
slug: string;
|
||||
active: boolean;
|
||||
brand_id: string;
|
||||
address?: string | null;
|
||||
zip?: string | null;
|
||||
cutoff_time?: string | null;
|
||||
};
|
||||
brands: Brand[];
|
||||
};
|
||||
|
||||
export default function StopEditForm({ stop, brands }: StopEditFormProps) {
|
||||
const router = useRouter();
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
const [city, setCity] = useState(stop.city);
|
||||
const [state, setState] = useState(stop.state);
|
||||
const [date, setDate] = useState(stop.date);
|
||||
const [time, setTime] = useState(stop.time);
|
||||
const [location, setLocation] = useState(stop.location);
|
||||
const [active, setActive] = useState(stop.active);
|
||||
const [brand_id, setBrand_id] = useState(stop.brand_id);
|
||||
const [address, setAddress] = useState(stop.address ?? "");
|
||||
const [zip, setZip] = useState(stop.zip ?? "");
|
||||
const [cutoff_time, setCutoff_time] = useState(stop.cutoff_time ?? "");
|
||||
|
||||
async function handleSave() {
|
||||
if (!city.trim() || !state.trim() || !date.trim()) {
|
||||
setError("City, state, and date are required.");
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
setSaved(false);
|
||||
|
||||
const slug = city.toLowerCase().replace(/\s+/g, "-") + "-" + date;
|
||||
|
||||
const result = await updateStop(stop.id, brand_id, {
|
||||
city,
|
||||
state,
|
||||
location,
|
||||
date,
|
||||
time,
|
||||
active,
|
||||
address: address || null,
|
||||
zip: zip || null,
|
||||
cutoff_time: cutoff_time || null,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
setError(result.error ?? "Failed to save");
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setSaved(true);
|
||||
setSaving(false);
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{error && (
|
||||
<div className="rounded-xl bg-red-900/30 p-4 text-sm text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{saved && (
|
||||
<div className="rounded-xl bg-green-900/30 p-4 text-sm text-green-400">
|
||||
Stop updated successfully.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-zinc-300">City</label>
|
||||
<input
|
||||
type="text"
|
||||
value={city}
|
||||
onChange={(e) => setCity(e.target.value)}
|
||||
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base outline-none focus:border-slate-900"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-zinc-300">State</label>
|
||||
<input
|
||||
type="text"
|
||||
value={state}
|
||||
onChange={(e) => setState(e.target.value)}
|
||||
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base outline-none focus:border-slate-900"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-zinc-300">Date</label>
|
||||
<input
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base outline-none focus:border-slate-900"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-zinc-300">Time</label>
|
||||
<input
|
||||
type="text"
|
||||
value={time}
|
||||
onChange={(e) => setTime(e.target.value)}
|
||||
placeholder="e.g. 8:00 AM – 2:00 PM"
|
||||
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base outline-none focus:border-slate-900"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-zinc-300">Location</label>
|
||||
<input
|
||||
type="text"
|
||||
value={location}
|
||||
onChange={(e) => setLocation(e.target.value)}
|
||||
placeholder="Street address or intersection"
|
||||
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base outline-none focus:border-slate-900"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-zinc-300">Street Address</label>
|
||||
<input
|
||||
type="text"
|
||||
value={address}
|
||||
onChange={(e) => setAddress(e.target.value)}
|
||||
placeholder="123 Main St"
|
||||
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base outline-none focus:border-slate-900"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-zinc-300">ZIP Code</label>
|
||||
<input
|
||||
type="text"
|
||||
value={zip}
|
||||
onChange={(e) => setZip(e.target.value)}
|
||||
placeholder="80102"
|
||||
maxLength={10}
|
||||
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base outline-none focus:border-slate-900"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-zinc-300">Order Cutoff</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={cutoff_time}
|
||||
onChange={(e) => setCutoff_time(e.target.value)}
|
||||
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base outline-none focus:border-slate-900"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-slate-400">
|
||||
Customers must order before this time to be included at this stop.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-zinc-300">Brand</label>
|
||||
<select
|
||||
value={brand_id}
|
||||
onChange={(e) => setBrand_id(e.target.value)}
|
||||
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base outline-none focus:border-slate-900"
|
||||
>
|
||||
{brands.map((b) => (
|
||||
<option key={b.id} value={b.id}>
|
||||
{b.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-zinc-300">Status</label>
|
||||
<button
|
||||
onClick={() => setActive((v) => !v)}
|
||||
className={`w-full rounded-xl px-4 py-3 text-sm font-medium transition-colors ${
|
||||
active
|
||||
? "bg-green-900/40 text-green-400"
|
||||
: "bg-zinc-950 text-zinc-400 hover:bg-slate-200"
|
||||
}`}
|
||||
>
|
||||
{active ? "Active" : "Inactive"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="w-full rounded-xl bg-slate-900 px-6 py-4 text-lg font-bold text-white disabled:opacity-50"
|
||||
>
|
||||
{saving ? "Saving..." : "Save Changes"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
type Customer = {
|
||||
id: string;
|
||||
customer_name: string;
|
||||
customer_email: string | null;
|
||||
customer_phone: string | null;
|
||||
pickup_complete: boolean;
|
||||
};
|
||||
|
||||
type StopMessagingFormProps = {
|
||||
stopId: string;
|
||||
};
|
||||
|
||||
const quickMessages = [
|
||||
{ label: "Truck running late", value: "Heads up — the truck is running about 15 minutes behind schedule. Thanks for your patience!" },
|
||||
{ label: "Stop moved", value: "Attention — today's stop location has changed to a new address. Please check the updated details." },
|
||||
{ label: "Weather delay", value: "Due to weather conditions, today's stop may be delayed. We'll send updates as the day progresses." },
|
||||
{ label: "Sold out", value: "This stop has sold out of several items. Check our website or next stop for availability." },
|
||||
{ label: "Preorder cutoff reminder", value: "Friendly reminder — the preorder cutoff for our next stop is tonight at midnight. Order online to guarantee your pickup!" },
|
||||
{ label: "Pickup reminder", value: "Reminder — you have an order ready for pickup today. See you soon!" },
|
||||
];
|
||||
|
||||
export default function StopMessagingForm({ stopId }: StopMessagingFormProps) {
|
||||
const [customers, setCustomers] = useState<Customer[]>([]);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [channel, setChannel] = useState<"sms" | "email" | "both">("sms");
|
||||
const [message, setMessage] = useState("");
|
||||
const [customMessage, setCustomMessage] = useState("");
|
||||
const [sending, setSending] = useState(false);
|
||||
const [sent, setSent] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function loadCustomers() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const res = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/orders?stop_id=eq.${stopId}&pickup_complete=eq.false&pickup_complete=eq.false&select=customer_name,customer_email,customer_phone,pickup_complete`,
|
||||
{
|
||||
headers: {
|
||||
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setError(data.message);
|
||||
} else {
|
||||
setCustomers(data);
|
||||
setLoaded(true);
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
function useQuickMessage(msg: string) {
|
||||
setMessage(msg);
|
||||
setCustomMessage(msg);
|
||||
}
|
||||
|
||||
async function handleSend() {
|
||||
if (!message.trim()) return;
|
||||
setSending(true);
|
||||
setError(null);
|
||||
|
||||
const recipients = customers.filter((c) => {
|
||||
if (channel === "sms") return c.customer_phone;
|
||||
if (channel === "email") return c.customer_email;
|
||||
return c.customer_phone || c.customer_email;
|
||||
});
|
||||
|
||||
// Call Supabase Edge Function to send messages
|
||||
const res = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/functions/v1/send-messages`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
channel,
|
||||
message,
|
||||
recipients: recipients.map((r) => ({
|
||||
phone: r.customer_phone,
|
||||
email: r.customer_email,
|
||||
name: r.customer_name,
|
||||
})),
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
setError(err.message ?? "Failed to send messages");
|
||||
setSending(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setSent(recipients.length);
|
||||
setSending(false);
|
||||
}
|
||||
|
||||
const recipients = customers.filter((c) => {
|
||||
if (channel === "sms") return c.customer_phone;
|
||||
if (channel === "email") return c.customer_email;
|
||||
return c.customer_phone || c.customer_email;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-zinc-100">
|
||||
Send Message
|
||||
</h2>
|
||||
<p className="mt-1 text-zinc-400">
|
||||
Notify all customers with pending pickups at this stop.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!loaded ? (
|
||||
<button
|
||||
onClick={loadCustomers}
|
||||
disabled={loading}
|
||||
className="w-full rounded-xl border-2 border-dashed border-zinc-600 px-6 py-4 text-lg font-medium text-zinc-500 disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Loading customers..." : "Load Pending Customers"}
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
{customers.length === 0 ? (
|
||||
<div className="rounded-xl bg-slate-50 p-6 text-center text-zinc-500">
|
||||
No pending orders for this stop yet.
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl bg-green-900/30 p-4 text-sm text-green-400">
|
||||
{customers.length} pending order{customers.length !== 1 ? "s" : ""} found
|
||||
{recipients.length !== customers.length && (
|
||||
<span> — {recipients.length} with contact info</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Channel */}
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-zinc-300">
|
||||
Send via
|
||||
</label>
|
||||
<div className="flex gap-3">
|
||||
{(["sms", "email", "both"] as const).map((ch) => (
|
||||
<button
|
||||
key={ch}
|
||||
onClick={() => setChannel(ch)}
|
||||
className={`flex-1 rounded-xl px-4 py-3 text-sm font-medium transition-colors ${
|
||||
channel === ch
|
||||
? "bg-slate-900 text-white"
|
||||
: "bg-zinc-950 text-zinc-400 hover:bg-slate-200"
|
||||
}`}
|
||||
>
|
||||
{ch === "sms" ? "📱 SMS" : ch === "email" ? "✉️ Email" : "📱+✉️ Both"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick messages */}
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-zinc-300">
|
||||
Quick messages
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{quickMessages.map((qm) => (
|
||||
<button
|
||||
key={qm.label}
|
||||
onClick={() => useQuickMessage(qm.value)}
|
||||
className={`rounded-full px-3 py-1 text-sm font-medium transition-colors ${
|
||||
message === qm.value
|
||||
? "bg-slate-900 text-white"
|
||||
: "bg-zinc-950 text-zinc-400 hover:bg-slate-200"
|
||||
}`}
|
||||
>
|
||||
{qm.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Message preview */}
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-zinc-300">
|
||||
Message
|
||||
</label>
|
||||
<textarea
|
||||
value={message}
|
||||
onChange={(e) => {
|
||||
setMessage(e.target.value);
|
||||
setCustomMessage(e.target.value);
|
||||
}}
|
||||
rows={4}
|
||||
className="w-full rounded-xl border border-zinc-600 px-4 py-3 text-base outline-none focus:border-slate-900"
|
||||
placeholder="Type your message..."
|
||||
/>
|
||||
<p className="mt-1 text-xs text-slate-400">
|
||||
{message.length} characters
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-xl bg-red-900/30 p-4 text-red-400 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sent > 0 && (
|
||||
<div className="rounded-xl bg-green-900/30 p-4 text-green-400 text-sm">
|
||||
Message sent to {sent} customer{sent !== 1 ? "s" : ""}!
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recipients */}
|
||||
{recipients.length > 0 && message && (
|
||||
<div className="rounded-xl border border-zinc-800 p-4">
|
||||
<p className="mb-3 text-sm font-medium text-zinc-300">
|
||||
Recipients ({recipients.length}):
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{recipients.map((c) => (
|
||||
<div key={c.id} className="flex justify-between text-sm">
|
||||
<span className="text-zinc-300">{c.customer_name}</span>
|
||||
<span className="text-slate-400">
|
||||
{c.customer_phone ?? c.customer_email ?? "no contact"}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={!message || recipients.length === 0 || sending}
|
||||
className="w-full rounded-xl bg-slate-900 px-6 py-4 text-lg font-bold text-white disabled:opacity-50"
|
||||
>
|
||||
{sending
|
||||
? "Sending..."
|
||||
: `Send to ${recipients.length} customer${recipients.length !== 1 ? "s" : ""}`}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { logAuditEvent } from "@/actions/audit";
|
||||
|
||||
type Product = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
price: number;
|
||||
};
|
||||
|
||||
type AssignedProduct = {
|
||||
id: string;
|
||||
product_id: string;
|
||||
products: { name: string; type: string; price: number } | null;
|
||||
};
|
||||
|
||||
type StopProductAssignmentProps = {
|
||||
stopId: string;
|
||||
allProducts: Product[];
|
||||
assignedProducts: AssignedProduct[];
|
||||
callerUid: string;
|
||||
};
|
||||
|
||||
export default function StopProductAssignment({
|
||||
stopId,
|
||||
allProducts,
|
||||
assignedProducts,
|
||||
callerUid,
|
||||
}: StopProductAssignmentProps) {
|
||||
const [products, setProducts] = useState(assignedProducts);
|
||||
const [selected, setSelected] = useState("");
|
||||
const [assigning, setAssigning] = useState(false);
|
||||
const [removing, setRemoving] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const assignedIds = new Set(products.map((p) => p.product_id));
|
||||
const availableProducts = allProducts.filter((p) => !assignedIds.has(p.id));
|
||||
|
||||
async function handleAssign(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!selected) return;
|
||||
|
||||
setAssigning(true);
|
||||
setError(null);
|
||||
|
||||
// First run diagnostic to understand the actual state
|
||||
const diagRes = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/debug_stop_product_assignment`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
p_stop_id: stopId,
|
||||
p_product_id: selected,
|
||||
p_caller_uid: callerUid,
|
||||
}),
|
||||
}
|
||||
);
|
||||
const diag = await diagRes.json();
|
||||
|
||||
// Now attempt the actual assignment
|
||||
const res = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/assign_product_to_stop`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
p_stop_id: stopId,
|
||||
p_product_id: selected,
|
||||
p_caller_uid: callerUid,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok || data.success === false) {
|
||||
const diagInfo = diag && typeof diag === 'object' && 'reason' in diag
|
||||
? `[${(diag as any).reason}] (admin_found=${(diag as any).admin_found}, role=${(diag as any).admin_role}, admin_brand=${(diag as any).admin_brand_id}, stop_brand=${(diag as any).stop_brand_id}, product_brand=${(diag as any).product_brand_id})`
|
||||
: '';
|
||||
const errMsg = data?.error ?? data?.message ?? `HTTP ${res.status}`;
|
||||
const errDetail = data?.details ?? data?.hint ?? data?.code ?? '';
|
||||
const fullError = diagInfo
|
||||
? `Failed to assign: ${errMsg} ${diagInfo}${errDetail ? ' | ' + errDetail : ''}`
|
||||
: `Failed to assign: ${errMsg}${errDetail ? ' — ' + errDetail : ''}`;
|
||||
setError(fullError);
|
||||
setAssigning(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Refresh product list from get_stop_products RPC
|
||||
const refreshRes = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/get_stop_products`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ p_stop_id: stopId }),
|
||||
}
|
||||
);
|
||||
const refreshData = await refreshRes.json();
|
||||
setProducts(refreshData.products ?? []);
|
||||
setSelected("");
|
||||
setAssigning(false);
|
||||
|
||||
logAuditEvent({
|
||||
table_name: "product_stops",
|
||||
record_id: data.id,
|
||||
action: "INSERT",
|
||||
old_data: null,
|
||||
new_data: { stop_id: stopId, product_id: selected },
|
||||
brand_id: null,
|
||||
});
|
||||
}
|
||||
|
||||
async function handleRemove(productId: string) {
|
||||
const entry = products.find((p) => p.product_id === productId);
|
||||
if (!entry) return;
|
||||
|
||||
setRemoving(productId);
|
||||
setError(null);
|
||||
|
||||
const res = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/unassign_product_from_stop`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
p_stop_id: stopId,
|
||||
p_product_id: productId,
|
||||
p_caller_uid: callerUid,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok || data.success === false) {
|
||||
const errMsg = data?.error ?? data?.message ?? `HTTP ${res.status}`;
|
||||
const errDetail = data?.details ?? data?.hint ?? data?.code ?? '';
|
||||
setError(`Failed to remove: ${errMsg}${errDetail ? ' — ' + errDetail : ''}`);
|
||||
setRemoving(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setProducts(products.filter((p) => p.product_id !== productId));
|
||||
setRemoving(null);
|
||||
|
||||
logAuditEvent({
|
||||
table_name: "product_stops",
|
||||
record_id: entry.id,
|
||||
action: "DELETE",
|
||||
old_data: { stop_id: stopId, product_id: productId },
|
||||
new_data: null,
|
||||
brand_id: null,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{error && (
|
||||
<div className="rounded-xl bg-red-900/30 p-4 text-sm text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{products.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-zinc-300">
|
||||
Assigned Products ({products.length})
|
||||
</p>
|
||||
{products.map((ap) => (
|
||||
<div
|
||||
key={ap.id}
|
||||
className="flex items-center justify-between rounded-xl border border-zinc-800 bg-slate-50 px-4 py-3"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-zinc-100">
|
||||
{ap.products?.name ?? "Unknown"}
|
||||
</p>
|
||||
<p className="text-xs text-zinc-500">
|
||||
{ap.products?.type} ·{" "}
|
||||
${Number(ap.products?.price ?? 0).toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleRemove(ap.product_id)}
|
||||
disabled={removing === ap.product_id}
|
||||
className="shrink-0 rounded-lg px-3 py-1 text-sm font-medium text-red-400 hover:bg-red-900/30 disabled:opacity-50"
|
||||
>
|
||||
{removing === ap.product_id ? "..." : "Remove"}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-zinc-500">No products assigned yet.</p>
|
||||
)}
|
||||
|
||||
{availableProducts.length > 0 && (
|
||||
<form onSubmit={handleAssign} className="flex gap-3">
|
||||
<select
|
||||
value={selected}
|
||||
onChange={(e) => setSelected(e.target.value)}
|
||||
className="flex-1 rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base outline-none focus:border-slate-900"
|
||||
>
|
||||
<option value="">Select a product...</option>
|
||||
{availableProducts.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name} ({p.type})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!selected || assigning}
|
||||
className="rounded-xl bg-slate-900 px-5 py-3 font-medium text-white disabled:opacity-50"
|
||||
>
|
||||
{assigning ? "..." : "Assign"}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
type Stop = {
|
||||
id: string;
|
||||
city: string;
|
||||
state: string;
|
||||
date: string;
|
||||
time: string;
|
||||
location: string;
|
||||
active: boolean;
|
||||
brands: { name: string } | { name: string }[];
|
||||
};
|
||||
|
||||
export default function StopTableBody({ stops }: { stops: Stop[] }) {
|
||||
return (
|
||||
<tbody className="divide-y divide-slate-200">
|
||||
{stops?.map((stop) => (
|
||||
<tr
|
||||
key={stop.id}
|
||||
onClick={() =>
|
||||
(window.location.href = `/admin/stops/${stop.id}`)
|
||||
}
|
||||
className="cursor-pointer hover:bg-zinc-800"
|
||||
>
|
||||
<td className="px-5 py-4">
|
||||
<span className="font-medium text-zinc-100">
|
||||
{stop.city}, {stop.state}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<td className="px-5 py-4 text-zinc-300">
|
||||
{stop.location}
|
||||
</td>
|
||||
|
||||
<td className="px-5 py-4 text-zinc-300">
|
||||
{stop.date}
|
||||
</td>
|
||||
|
||||
<td className="px-5 py-4 text-zinc-300">
|
||||
{stop.time}
|
||||
</td>
|
||||
|
||||
<td className="px-5 py-4 text-zinc-300">
|
||||
{Array.isArray(stop.brands)
|
||||
? stop.brands[0]?.name
|
||||
: stop.brands?.name}
|
||||
</td>
|
||||
|
||||
<td className="px-5 py-4">
|
||||
<span className="rounded-full bg-zinc-950 px-3 py-1 text-xs font-medium text-zinc-300">
|
||||
{stop.active ? "Active" : "Inactive"}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useTransition } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { publishStop } from "@/actions/stops";
|
||||
|
||||
type Stop = {
|
||||
id: string;
|
||||
city: string;
|
||||
state: string;
|
||||
date: string;
|
||||
time: string;
|
||||
location: string;
|
||||
active: boolean;
|
||||
deleted_at?: string | null;
|
||||
brand_id: string;
|
||||
status?: string;
|
||||
brands: { name: string } | { name: string }[];
|
||||
};
|
||||
|
||||
type Props = {
|
||||
stops: Stop[];
|
||||
};
|
||||
|
||||
export default function StopTableClient({ stops }: Props) {
|
||||
const router = useRouter();
|
||||
const [, startTransition] = useTransition();
|
||||
const [search, setSearch] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<"all" | "active" | "inactive" | "draft">("all");
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
const [page, setPage] = useState(0);
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
const filtered = stops.filter((s) => {
|
||||
const matchesSearch =
|
||||
!search ||
|
||||
s.city.toLowerCase().includes(search.toLowerCase()) ||
|
||||
s.location.toLowerCase().includes(search.toLowerCase());
|
||||
const matchesStatus =
|
||||
statusFilter === "all" ||
|
||||
(statusFilter === "active" && s.active && s.status !== "draft") ||
|
||||
(statusFilter === "inactive" && !s.active && s.status !== "draft") ||
|
||||
(statusFilter === "draft" && s.status === "draft");
|
||||
return matchesSearch && matchesStatus;
|
||||
});
|
||||
|
||||
const totalPages = Math.ceil(filtered.length / PAGE_SIZE);
|
||||
const paginatedStops = filtered.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
|
||||
|
||||
function handleDeleted() {
|
||||
setDeleteError(null);
|
||||
startTransition(() => {
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Filter bar */}
|
||||
<div className="border-b border-stone-200 px-5 py-3 flex gap-3 flex-wrap items-center">
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search stops..."
|
||||
value={search}
|
||||
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
|
||||
className="flex-1 min-w-40 rounded-lg border border-stone-200 bg-stone-50 px-3 py-2 text-sm text-stone-900 outline-none focus:border-emerald-500 placeholder:text-stone-400 transition-colors"
|
||||
/>
|
||||
<div className="flex gap-1 rounded-lg border border-stone-200 bg-white p-1">
|
||||
{(["all", "active", "inactive", "draft"] as const).map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => { setStatusFilter(f); setPage(0); }}
|
||||
className={`rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
|
||||
statusFilter === f
|
||||
? "bg-emerald-100 text-emerald-700 border border-emerald-200"
|
||||
: "text-stone-500 hover:text-stone-700 hover:bg-stone-50"
|
||||
}`}
|
||||
>
|
||||
{f === "all" ? "All" : f === "active" ? "Active" : f === "inactive" ? "Inactive" : "Draft"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-xs text-stone-500">{filtered.length} stops</span>
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center gap-1 ml-auto">
|
||||
<button onClick={() => setPage(p => Math.max(0, p - 1))} disabled={page === 0}
|
||||
className="flex h-7 w-7 items-center justify-center rounded border border-stone-200 text-stone-500 hover:text-stone-700 hover:bg-stone-50 disabled:opacity-30 disabled:cursor-not-allowed transition-colors">
|
||||
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" /></svg>
|
||||
</button>
|
||||
<span className="text-xs text-stone-500 px-1">{page + 1}/{totalPages}</span>
|
||||
<button onClick={() => setPage(p => Math.min(totalPages - 1, p + 1))} disabled={page >= totalPages - 1}
|
||||
className="flex h-7 w-7 items-center justify-center rounded border border-stone-200 text-stone-500 hover:text-stone-700 hover:bg-stone-50 disabled:opacity-30 disabled:cursor-not-allowed transition-colors">
|
||||
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Delete error */}
|
||||
{deleteError && (
|
||||
<div className="mx-5 my-3 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{deleteError}{" "}
|
||||
<button onClick={() => setDeleteError(null)} className="underline hover:no-underline">
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="bg-stone-50 text-stone-500">
|
||||
<tr>
|
||||
<th className="px-5 py-4 font-semibold">City</th>
|
||||
<th className="px-5 py-4 font-semibold">Location</th>
|
||||
<th className="px-5 py-4 font-semibold">Date</th>
|
||||
<th className="px-5 py-4 font-semibold">Time</th>
|
||||
<th className="px-5 py-4 font-semibold">Brand</th>
|
||||
<th className="px-5 py-4 font-semibold">Status</th>
|
||||
<th className="px-5 py-4 font-semibold" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-stone-100">
|
||||
{filtered.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-5 py-10 text-center text-sm text-stone-500">
|
||||
{search || statusFilter !== "all"
|
||||
? "No stops match your search."
|
||||
: "No stops found. Create one to get started."}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
paginatedStops.map((stop) => (
|
||||
<StopRow
|
||||
key={stop.id}
|
||||
stop={stop}
|
||||
onDeleted={handleDeleted}
|
||||
onDeleteError={setDeleteError}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function StopRowBase({
|
||||
stop,
|
||||
onDeleted,
|
||||
onDeleteError,
|
||||
}: {
|
||||
stop: Stop;
|
||||
onDeleted: () => void;
|
||||
onDeleteError: (msg: string) => void;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [, startTransition] = useTransition();
|
||||
const [openMenu, setOpenMenu] = useState<string | null>(null);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
|
||||
const [publishingId, setPublishingId] = useState<string | null>(null);
|
||||
|
||||
async function handlePublish(stopId: string) {
|
||||
setPublishingId(stopId);
|
||||
setOpenMenu(null);
|
||||
const result = await publishStop(stopId, stop.brand_id);
|
||||
setPublishingId(null);
|
||||
if (result.success) {
|
||||
startTransition(() => router.refresh());
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(stopId: string) {
|
||||
setDeletingId(stopId);
|
||||
const res = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/delete_stop`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ p_stop_id: stopId, p_brand_id: stop.brand_id }),
|
||||
}
|
||||
);
|
||||
const data = await res.json();
|
||||
setDeletingId(null);
|
||||
setConfirmDelete(null);
|
||||
setOpenMenu(null);
|
||||
if (data.success) {
|
||||
onDeleted();
|
||||
} else {
|
||||
onDeleteError(data.error ?? "Delete failed");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<tr className="hover:bg-stone-50 transition-colors relative">
|
||||
<td className="px-5 py-4">
|
||||
<Link
|
||||
href={`/admin/stops/${stop.id}`}
|
||||
className="font-medium text-stone-900 hover:text-emerald-600 transition-colors"
|
||||
>
|
||||
{stop.city}, {stop.state}
|
||||
</Link>
|
||||
</td>
|
||||
|
||||
<td className="px-5 py-4 text-stone-600">{stop.location}</td>
|
||||
|
||||
<td className="px-5 py-4 font-mono text-stone-500 text-xs">{stop.date}</td>
|
||||
|
||||
<td className="px-5 py-4 font-mono text-stone-500 text-xs">{stop.time}</td>
|
||||
|
||||
<td className="px-5 py-4 text-stone-600">
|
||||
{Array.isArray(stop.brands)
|
||||
? stop.brands[0]?.name
|
||||
: stop.brands?.name}
|
||||
</td>
|
||||
|
||||
<td className="px-5 py-4">
|
||||
<span
|
||||
className={`rounded-full px-3 py-1 text-xs font-medium ${
|
||||
stop.status === "draft"
|
||||
? "bg-amber-100 text-amber-700 border border-amber-200"
|
||||
: stop.active
|
||||
? "bg-emerald-100 text-emerald-700 border border-emerald-200"
|
||||
: "bg-stone-100 text-stone-600 border border-stone-200"
|
||||
}`}
|
||||
>
|
||||
{stop.status === "draft" ? "Draft" : stop.active ? "Active" : "Inactive"}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<td className="px-5 py-4 text-right">
|
||||
<div className="relative inline-flex items-center justify-end gap-2">
|
||||
<Link
|
||||
href={`/admin/stops/${stop.id}`}
|
||||
className="rounded-lg px-3 py-1.5 text-xs font-medium text-stone-600 hover:text-stone-900 hover:bg-stone-100 transition-colors"
|
||||
>
|
||||
Edit
|
||||
</Link>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setOpenMenu(openMenu === stop.id ? null : stop.id);
|
||||
}}
|
||||
className="rounded-lg px-2 py-1.5 text-xs text-stone-400 hover:text-stone-600 hover:bg-stone-100 transition-colors"
|
||||
>
|
||||
⋮
|
||||
</button>
|
||||
|
||||
{openMenu === stop.id && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-10"
|
||||
onClick={() => { setOpenMenu(null); setConfirmDelete(null); }}
|
||||
/>
|
||||
<div className="absolute right-0 top-full mt-1 z-20 w-44 rounded-xl bg-white border border-stone-200 shadow-xl overflow-hidden">
|
||||
{stop.status === "draft" && (
|
||||
<button
|
||||
onClick={() => handlePublish(stop.id)}
|
||||
disabled={publishingId === stop.id}
|
||||
className="w-full text-left px-4 py-2.5 text-sm text-emerald-600 hover:bg-emerald-50 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{publishingId === stop.id ? "Publishing..." : "Publish"}
|
||||
</button>
|
||||
)}
|
||||
<a
|
||||
href={`/admin/stops/new?duplicate=${stop.id}`}
|
||||
className="flex items-center gap-2 px-4 py-2.5 text-sm text-stone-600 hover:bg-stone-50 transition-colors"
|
||||
>
|
||||
Duplicate
|
||||
</a>
|
||||
<button
|
||||
onClick={() => { setOpenMenu(null); setConfirmDelete(stop.id); }}
|
||||
className="w-full text-left px-4 py-2.5 text-sm text-red-600 hover:bg-red-50 transition-colors"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{confirmDelete === stop.id && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-30"
|
||||
onClick={() => { setConfirmDelete(null); setOpenMenu(null); }}
|
||||
/>
|
||||
<div className="absolute right-0 top-full mt-1 z-40 w-72 rounded-xl bg-white border border-stone-200 shadow-xl p-4">
|
||||
<p className="text-sm font-semibold text-stone-900">
|
||||
Delete "{stop.city}, {stop.state}"?
|
||||
</p>
|
||||
<p className="mt-1.5 text-xs text-stone-500">
|
||||
This will remove the stop. If it has active orders, you must resolve those first.
|
||||
</p>
|
||||
<div className="mt-4 flex gap-2">
|
||||
<button
|
||||
onClick={() => { setConfirmDelete(null); setOpenMenu(null); }}
|
||||
className="flex-1 rounded-lg border border-stone-200 px-3 py-2 text-xs font-medium text-stone-700 hover:bg-stone-50 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(stop.id)}
|
||||
disabled={deletingId === stop.id}
|
||||
className="flex-1 rounded-lg bg-red-600 px-3 py-2 text-xs font-medium text-white hover:bg-red-500 disabled:opacity-50 transition-colors shadow-sm"
|
||||
>
|
||||
{deletingId === stop.id ? "..." : "Delete"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
const StopRow = React.memo(StopRowBase);
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import ScheduleImportModal from "@/components/admin/ScheduleImportModal";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
type Props = {
|
||||
brandId: string;
|
||||
};
|
||||
|
||||
export default function StopsHeaderActions({ brandId }: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
function handleComplete(count: number) {
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="rounded-xl border border-zinc-700 bg-zinc-900 px-4 py-3 text-sm font-medium text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600 transition-colors"
|
||||
>
|
||||
Upload Schedule
|
||||
</button>
|
||||
<a
|
||||
href="/admin/stops/new"
|
||||
className="rounded-xl bg-emerald-600 hover:bg-emerald-500 active:bg-emerald-700 px-5 py-3 text-sm font-semibold text-white transition-colors shadow-lg shadow-emerald-900/30"
|
||||
>
|
||||
+ Add Stop
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<ScheduleImportModal
|
||||
brandId={brandId}
|
||||
onClose={() => setOpen(false)}
|
||||
onComplete={handleComplete}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,461 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { formatDate } from "@/lib/date-utils";
|
||||
import {
|
||||
exportTaxReport,
|
||||
exportTaxByState,
|
||||
type TaxOrderRow,
|
||||
type TaxByStateRow,
|
||||
} from "@/lib/reports-export";
|
||||
|
||||
type DatePreset = "month" | "quarter" | "this_year" | "custom";
|
||||
|
||||
type DateRange = { start: string; end: string };
|
||||
|
||||
function buildRange(preset: DatePreset, customStart?: string, customEnd?: string): DateRange {
|
||||
const now = new Date();
|
||||
const toDateStr = (d: Date) => d.toISOString().slice(0, 10);
|
||||
const today = toDateStr(now);
|
||||
|
||||
switch (preset) {
|
||||
case "month": {
|
||||
const start = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
return { start: toDateStr(start), end: today };
|
||||
}
|
||||
case "quarter": {
|
||||
const q = Math.floor(now.getMonth() / 3);
|
||||
const start = new Date(now.getFullYear(), q * 3, 1);
|
||||
return { start: toDateStr(start), end: today };
|
||||
}
|
||||
case "this_year": {
|
||||
const start = new Date(now.getFullYear(), 0, 1);
|
||||
return { start: toDateStr(start), end: today };
|
||||
}
|
||||
case "custom":
|
||||
return { start: customStart ?? today, end: customEnd ?? today };
|
||||
}
|
||||
}
|
||||
|
||||
function quarterLabel(start: string, end: string): string {
|
||||
const s = new Date(start + "T00:00:00");
|
||||
const q = Math.floor(s.getMonth() / 3) + 1;
|
||||
return `Q${q} ${s.getFullYear()}`;
|
||||
}
|
||||
|
||||
type TaxSummaryData = {
|
||||
total_tax_collected: number;
|
||||
total_gross_sales: number;
|
||||
order_count: number;
|
||||
tax_by_state: Array<{
|
||||
state: string;
|
||||
total_tax: number;
|
||||
gross_sales: number;
|
||||
order_count: number;
|
||||
}>;
|
||||
};
|
||||
|
||||
function SummaryCard({
|
||||
label,
|
||||
value,
|
||||
prefix,
|
||||
suffix,
|
||||
highlight,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
prefix?: string;
|
||||
suffix?: string;
|
||||
highlight?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className={`rounded-xl border px-4 py-4 ${highlight ? "border-amber-300 bg-amber-900/30" : "border-zinc-800 bg-zinc-900"}`}>
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-zinc-500">{label}</p>
|
||||
<p className={`mt-1 text-2xl font-semibold ${highlight ? "text-amber-700" : "text-zinc-100"}`}>
|
||||
{prefix ?? ""}{typeof value === "number" ? value.toLocaleString(undefined, { maximumFractionDigits: 2 }) : value}{suffix ?? ""}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ExportBtn({ label, onClick }: { label: string; onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="rounded-lg border border-zinc-600 px-3 py-1.5 text-xs font-medium text-zinc-400 hover:bg-zinc-800"
|
||||
>
|
||||
Export CSV
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function ReportTable({ headers, rows, renderRow }: {
|
||||
headers: string[];
|
||||
rows: unknown[];
|
||||
renderRow: (row: unknown, i: number) => React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-xl border border-zinc-800 bg-zinc-900">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-800 bg-slate-50">
|
||||
{headers.map((h) => (
|
||||
<th key={h} className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-zinc-500">
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={headers.length} className="px-3 py-6 text-center text-slate-400">
|
||||
No data
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
rows.map((_, i) => renderRow(_, i))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const TABS = [
|
||||
{ id: "summary", label: "Tax Summary" },
|
||||
{ id: "orders", label: "Taxable Orders" },
|
||||
];
|
||||
|
||||
export default function TaxDashboard({
|
||||
brands,
|
||||
initialBrandId,
|
||||
isPlatformAdmin,
|
||||
}: {
|
||||
brands: { id: string; name: string }[];
|
||||
initialBrandId: string | null;
|
||||
isPlatformAdmin: boolean;
|
||||
}) {
|
||||
const [preset, setPreset] = useState<DatePreset>("quarter");
|
||||
const [customStart, setCustomStart] = useState("");
|
||||
const [customEnd, setCustomEnd] = useState("");
|
||||
const [selectedBrandId, setSelectedBrandId] = useState<string>(initialBrandId ?? "");
|
||||
const [activeTab, setActiveTab] = useState("summary");
|
||||
const [summary, setSummary] = useState<TaxSummaryData | null>(null);
|
||||
const [orders, setOrders] = useState<TaxOrderRow[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const range: DateRange = buildRange(preset, customStart, customEnd);
|
||||
|
||||
const fetchTaxSummary = useCallback(async () => {
|
||||
if (!selectedBrandId) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/get_tax_summary`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
p_brand_id: selectedBrandId,
|
||||
p_start_date: range.start,
|
||||
p_end_date: range.end,
|
||||
}),
|
||||
}
|
||||
);
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const data = await res.json();
|
||||
setSummary({
|
||||
total_tax_collected: Number(data?.total_tax_collected ?? 0),
|
||||
total_gross_sales: Number(data?.total_gross_sales ?? 0),
|
||||
order_count: Number(data?.order_count ?? 0),
|
||||
tax_by_state: data?.tax_by_state ?? [],
|
||||
});
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load tax summary");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [selectedBrandId, range]);
|
||||
|
||||
const fetchTaxableOrders = useCallback(async () => {
|
||||
if (!selectedBrandId) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/get_taxable_orders`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
p_brand_id: selectedBrandId,
|
||||
p_start_date: range.start,
|
||||
p_end_date: range.end,
|
||||
}),
|
||||
}
|
||||
);
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const data = await res.json();
|
||||
setOrders(
|
||||
(data ?? []).map((row: Record<string, unknown>) => ({
|
||||
order_id: String(row.order_id ?? ""),
|
||||
date: String(row.date ?? ""),
|
||||
customer_name: String(row.customer_name ?? ""),
|
||||
city: String(row.city ?? ""),
|
||||
state: String(row.state ?? ""),
|
||||
taxable_amount: Number(row.taxable_amount ?? 0),
|
||||
tax_amount: Number(row.tax_amount ?? 0),
|
||||
tax_rate: Number(row.tax_rate ?? 0),
|
||||
tax_location: String(row.tax_location ?? ""),
|
||||
}))
|
||||
);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load orders");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [selectedBrandId, range]);
|
||||
|
||||
function handleFetch() {
|
||||
if (activeTab === "summary") fetchTaxSummary();
|
||||
else fetchTaxableOrders();
|
||||
}
|
||||
|
||||
// Auto-fetch when tab/brand/range changes
|
||||
if (summary === null && activeTab === "summary" && selectedBrandId && !loading) {
|
||||
fetchTaxSummary();
|
||||
}
|
||||
if (orders.length === 0 && activeTab === "orders" && selectedBrandId && !loading) {
|
||||
fetchTaxableOrders();
|
||||
}
|
||||
|
||||
function handleExportCSV() {
|
||||
if (activeTab === "summary" && summary) {
|
||||
const rows: TaxByStateRow[] = summary.tax_by_state.map((s) => ({
|
||||
state: s.state,
|
||||
total_tax: s.total_tax,
|
||||
gross_sales: s.gross_sales,
|
||||
order_count: s.order_count,
|
||||
}));
|
||||
const csv = exportTaxByState(rows);
|
||||
downloadCSV(csv, `tax-by-state-${range.start}.csv`);
|
||||
} else if (activeTab === "orders" && orders.length > 0) {
|
||||
const csv = exportTaxReport(orders);
|
||||
downloadCSV(csv, `taxable-orders-${range.start}.csv`);
|
||||
}
|
||||
}
|
||||
|
||||
function downloadCSV(csv: string, filename: string) {
|
||||
const blob = new Blob([csv], { type: "text/csv" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
const effectiveRate = summary && summary.total_gross_sales > 0
|
||||
? (summary.total_tax_collected / summary.total_gross_sales) * 100
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{error && (
|
||||
<div className="rounded-xl bg-red-900/30 border border-red-200 px-4 py-3 text-sm text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Filters ── */}
|
||||
<div className="flex flex-wrap items-center gap-3 rounded-xl bg-zinc-900 border border-zinc-800 px-5 py-4">
|
||||
<div className="flex gap-1">
|
||||
{(["month", "quarter", "this_year", "custom"] as DatePreset[]).map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => setPreset(p)}
|
||||
className={`rounded-lg px-3 py-1.5 text-xs font-medium ${
|
||||
preset === p
|
||||
? "bg-slate-900 text-white"
|
||||
: "bg-zinc-950 text-zinc-400 hover:bg-slate-200"
|
||||
}`}
|
||||
>
|
||||
{p === "month" ? "This Month" : p === "quarter" ? "This Quarter" : p === "this_year" ? "This Year" : "Custom"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{preset === "custom" && (
|
||||
<div className="flex gap-2 items-center">
|
||||
<input
|
||||
type="date"
|
||||
value={customStart}
|
||||
onChange={(e) => setCustomStart(e.target.value)}
|
||||
className="rounded-lg border border-zinc-600 px-3 py-1.5 text-xs"
|
||||
/>
|
||||
<span className="text-slate-400 text-xs">to</span>
|
||||
<input
|
||||
type="date"
|
||||
value={customEnd}
|
||||
onChange={(e) => setCustomEnd(e.target.value)}
|
||||
className="rounded-lg border border-zinc-600 px-3 py-1.5 text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isPlatformAdmin && (
|
||||
<select
|
||||
value={selectedBrandId}
|
||||
onChange={(e) => {
|
||||
setSelectedBrandId(e.target.value);
|
||||
setSummary(null);
|
||||
setOrders([]);
|
||||
}}
|
||||
className="rounded-lg border border-zinc-600 px-3 py-1.5 text-xs"
|
||||
>
|
||||
<option value="">All Brands</option>
|
||||
{brands.map((b) => (
|
||||
<option key={b.id} value={b.id}>{b.name}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleFetch}
|
||||
disabled={loading || !selectedBrandId}
|
||||
className="rounded-lg bg-blue-600 px-4 py-1.5 text-xs font-medium text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Loading..." : "Refresh"}
|
||||
</button>
|
||||
|
||||
<span className="ml-auto text-xs text-zinc-500">
|
||||
{preset === "quarter" ? quarterLabel(range.start, range.end) :
|
||||
preset === "this_year" ? `${new Date().getFullYear()} YTD` :
|
||||
`${range.start} → ${range.end}`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* ── Tab bar ── */}
|
||||
<div className="flex gap-1 border-b border-zinc-800">
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px ${
|
||||
activeTab === tab.id
|
||||
? "border-blue-600 text-blue-400"
|
||||
: "border-transparent text-zinc-500 hover:text-zinc-300"
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── TAX SUMMARY TAB ── */}
|
||||
{activeTab === "summary" && (
|
||||
<div className="space-y-4">
|
||||
{summary && (
|
||||
<>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<SummaryCard
|
||||
label="Total Tax Collected"
|
||||
value={summary.total_tax_collected}
|
||||
prefix="$"
|
||||
highlight
|
||||
/>
|
||||
<SummaryCard
|
||||
label="Total Gross Sales"
|
||||
value={summary.total_gross_sales}
|
||||
prefix="$"
|
||||
/>
|
||||
<SummaryCard
|
||||
label="Effective Tax Rate"
|
||||
value={effectiveRate}
|
||||
suffix="%"
|
||||
/>
|
||||
<SummaryCard
|
||||
label="Taxable Orders"
|
||||
value={summary.order_count}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-zinc-300">Tax by State</h3>
|
||||
{summary.tax_by_state.length > 0 && (
|
||||
<ExportBtn label="Export CSV" onClick={handleExportCSV} />
|
||||
)}
|
||||
</div>
|
||||
<ReportTable
|
||||
headers={["State", "Total Tax", "Gross Sales", "Orders", "Eff. Rate"]}
|
||||
rows={summary.tax_by_state}
|
||||
renderRow={(row) => {
|
||||
const r = row as { state: string; total_tax: number; gross_sales: number; order_count: number };
|
||||
const rate = r.gross_sales > 0 ? (r.total_tax / r.gross_sales) * 100 : 0;
|
||||
return (
|
||||
<tr key={r.state} className="border-t border-slate-100 hover:bg-zinc-800">
|
||||
<td className="px-3 py-2 font-medium text-zinc-100">{r.state}</td>
|
||||
<td className="px-3 py-2 text-right text-amber-700 font-medium">${r.total_tax.toFixed(2)}</td>
|
||||
<td className="px-3 py-2 text-right">${r.gross_sales.toFixed(2)}</td>
|
||||
<td className="px-3 py-2 text-right">{r.order_count}</td>
|
||||
<td className="px-3 py-2 text-right text-zinc-500">{rate.toFixed(2)}%</td>
|
||||
</tr>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!summary && !loading && (
|
||||
<div className="rounded-xl border border-zinc-800 bg-zinc-900 px-6 py-12 text-center text-slate-400">
|
||||
Click Refresh to load tax summary
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── TAXABLE ORDERS TAB ── */}
|
||||
{activeTab === "orders" && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-zinc-300">Taxable Orders</h3>
|
||||
{orders.length > 0 && (
|
||||
<ExportBtn label="Export CSV" onClick={handleExportCSV} />
|
||||
)}
|
||||
</div>
|
||||
<ReportTable
|
||||
headers={["Order ID", "Date", "Customer", "City", "State", "Taxable Amt", "Tax", "Rate", "Location"]}
|
||||
rows={orders}
|
||||
renderRow={(row) => {
|
||||
const r = row as TaxOrderRow;
|
||||
return (
|
||||
<tr key={r.order_id} className="border-t border-slate-100 hover:bg-zinc-800">
|
||||
<td className="px-3 py-2 font-mono text-xs text-zinc-400">{r.order_id.slice(0, 8)}</td>
|
||||
<td className="px-3 py-2 text-zinc-400">{r.date}</td>
|
||||
<td className="px-3 py-2 font-medium text-zinc-100">{r.customer_name}</td>
|
||||
<td className="px-3 py-2 text-zinc-400">{r.city}</td>
|
||||
<td className="px-3 py-2 text-zinc-400">{r.state}</td>
|
||||
<td className="px-3 py-2 text-right">${Number(r.taxable_amount).toFixed(2)}</td>
|
||||
<td className="px-3 py-2 text-right text-amber-700 font-medium">${Number(r.tax_amount).toFixed(2)}</td>
|
||||
<td className="px-3 py-2 text-right text-zinc-500">{Number(r.tax_rate).toFixed(4)}</td>
|
||||
<td className="px-3 py-2 text-zinc-500 text-xs">{r.tax_location}</td>
|
||||
</tr>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
type TaxSummaryData = {
|
||||
total_tax_collected: number;
|
||||
total_gross_sales: number;
|
||||
order_count: number;
|
||||
};
|
||||
|
||||
function quarterLabel(): string {
|
||||
const now = new Date();
|
||||
const q = Math.floor(now.getMonth() / 3) + 1;
|
||||
return `Q${q} ${now.getFullYear()}`;
|
||||
}
|
||||
|
||||
function quarterDateRange(): { start: string; end: string } {
|
||||
const now = new Date();
|
||||
const q = Math.floor(now.getMonth() / 3);
|
||||
const startDate = new Date(now.getFullYear(), q * 3, 1);
|
||||
const end = now.toISOString().slice(0, 10);
|
||||
return { start: startDate.toISOString().slice(0, 10), end };
|
||||
}
|
||||
|
||||
export default function TaxQuarterlySummary({ brandId }: { brandId: string }) {
|
||||
const [data, setData] = useState<TaxSummaryData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const range = quarterDateRange();
|
||||
fetch(
|
||||
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/get_tax_summary`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
p_brand_id: brandId,
|
||||
p_start_date: range.start,
|
||||
p_end_date: range.end,
|
||||
}),
|
||||
}
|
||||
)
|
||||
.then((r) => r.json())
|
||||
.then((d) => {
|
||||
setData({
|
||||
total_tax_collected: Number(d?.total_tax_collected ?? 0),
|
||||
total_gross_sales: Number(d?.total_gross_sales ?? 0),
|
||||
order_count: Number(d?.order_count ?? 0),
|
||||
});
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => setLoading(false));
|
||||
}, [brandId]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 flex items-center gap-2">
|
||||
<div className="h-4 w-4 rounded-full border-2 border-amber-400 border-t-transparent animate-spin" />
|
||||
<span className="text-xs text-amber-700">Loading tax summary...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data || data.order_count === 0) return null;
|
||||
|
||||
const effectiveRate = data.total_gross_sales > 0
|
||||
? (data.total_tax_collected / data.total_gross_sales) * 100
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-amber-700">
|
||||
{quarterLabel()} Tax Collected
|
||||
</span>
|
||||
</div>
|
||||
<Link
|
||||
href="/admin/taxes"
|
||||
className="text-xs text-amber-600 hover:text-amber-800 font-medium"
|
||||
>
|
||||
View Details →
|
||||
</Link>
|
||||
</div>
|
||||
<div className="mt-2 flex items-baseline gap-4">
|
||||
<span className="text-xl font-bold text-amber-800">
|
||||
${data.total_tax_collected.toLocaleString(undefined, { minimumFractionDigits: 2 })}
|
||||
</span>
|
||||
<span className="text-xs text-amber-600">
|
||||
on ${data.total_gross_sales.toLocaleString(undefined, { minimumFractionDigits: 2 })} gross sales · {effectiveRate.toFixed(3)}% rate · {data.order_count} orders
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,446 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { Template, TemplateType, CampaignType } from "@/actions/communications/templates";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { upsertTemplate } from "@/actions/communications/templates";
|
||||
import {
|
||||
BUILT_IN_TEMPLATES,
|
||||
renderTemplate,
|
||||
TEMPLATE_VARIABLES,
|
||||
type EmailTemplate,
|
||||
} from "@/lib/email-templates";
|
||||
|
||||
const TEMPLATE_TYPES: { value: TemplateType; label: string }[] = [
|
||||
{ value: "email_template", label: "Email" },
|
||||
{ value: "sms_template", label: "SMS" },
|
||||
{ value: "internal_note", label: "Internal Note" },
|
||||
];
|
||||
|
||||
const CAMPAIGN_TYPES: { value: CampaignType; label: string }[] = [
|
||||
{ value: "operational", label: "Operational" },
|
||||
{ value: "marketing", label: "Marketing" },
|
||||
{ value: "transactional", label: "Transactional" },
|
||||
];
|
||||
|
||||
// Sample variables for preview rendering
|
||||
const SAMPLE_VARS: Record<string, string> = {
|
||||
first_name: "Jane",
|
||||
company_name: "Route Commerce",
|
||||
stop_name: "Tuxedo Warehouse",
|
||||
pickup_date: "May 15, 2026",
|
||||
order_total: "$450.00",
|
||||
balance_due: "$225.00",
|
||||
due_date: "May 10, 2026",
|
||||
item_summary: "10× Tuxedo Set, 5× Dress Shirt",
|
||||
custom_message: "We look forward to seeing you!",
|
||||
cta_text: "Shop Now",
|
||||
cta_url: "https://routecommerce.com",
|
||||
tag: "New Arrivals",
|
||||
headline: "Spring Collection is Here!",
|
||||
unsubscribe_url: "#",
|
||||
};
|
||||
|
||||
export function TemplateListPanel({ templates }: { templates: Template[] }) {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-zinc-100">Templates</h2>
|
||||
<p className="text-sm text-zinc-500">{templates.length} template{templates.length !== 1 ? "s" : ""}</p>
|
||||
</div>
|
||||
<a
|
||||
href="/admin/communications/templates/new"
|
||||
className="inline-flex items-center gap-1 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
|
||||
>
|
||||
+ New Template
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{templates.length === 0 ? (
|
||||
<div className="text-center py-12 text-slate-400">No templates yet</div>
|
||||
) : (
|
||||
<div className="bg-zinc-900 rounded-xl border border-zinc-800 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-zinc-900 border-b border-zinc-800">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3 font-medium text-zinc-400">Name</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-zinc-400">Type</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-zinc-400">Subject</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-zinc-400">Updated</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{templates.map((t) => (
|
||||
<tr key={t.id} className="hover:bg-zinc-900 cursor-pointer" onClick={() => router.push(`/admin/communications/templates/${t.id}`)}>
|
||||
<td className="px-4 py-3 font-medium text-zinc-100">{t.name}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${
|
||||
t.template_type === "email_template" ? "bg-blue-900/40 text-blue-700" :
|
||||
t.template_type === "sms_template" ? "bg-green-900/40 text-green-400" :
|
||||
"bg-zinc-950 text-zinc-400"
|
||||
}`}>
|
||||
{t.template_type}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-zinc-400 truncate max-w-xs">{t.subject}</td>
|
||||
<td className="px-4 py-3 text-zinc-500">{formatDate(new Date(t.updated_at))}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TemplateEditForm({
|
||||
template,
|
||||
mode = "edit",
|
||||
brandId,
|
||||
}: {
|
||||
template?: Template;
|
||||
mode?: "edit" | "new";
|
||||
brandId: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [name, setName] = useState(template?.name ?? "");
|
||||
const [subject, setSubject] = useState(template?.subject ?? "");
|
||||
const [bodyText, setBodyText] = useState(template?.body_text ?? "");
|
||||
const [bodyHtml, setBodyHtml] = useState(template?.body_html ?? "");
|
||||
const [templateType, setTemplateType] = useState<TemplateType>(template?.template_type ?? "email_template");
|
||||
const [campaignType, setCampaignType] = useState<CampaignType | "">(template?.campaign_type ?? "");
|
||||
const [error, setError] = useState("");
|
||||
const [activeTab, setActiveTab] = useState<"edit" | "preview">("edit");
|
||||
const [previewDevice, setPreviewDevice] = useState<"desktop" | "mobile">("desktop");
|
||||
const [htmlMode, setHtmlMode] = useState(!!template?.body_html);
|
||||
|
||||
// Apply a built-in template to the editor fields
|
||||
function applyBuiltIn(tpl: EmailTemplate) {
|
||||
const rendered = renderTemplate(tpl, SAMPLE_VARS);
|
||||
setName(tpl.name);
|
||||
setSubject(rendered.subject);
|
||||
setBodyText(rendered.body_text);
|
||||
setBodyHtml(rendered.body_html);
|
||||
setHtmlMode(true);
|
||||
}
|
||||
|
||||
function insertVariable(key: string) {
|
||||
const token = `{{${key}}}`;
|
||||
const ta = document.getElementById("body-textarea") as HTMLTextAreaElement;
|
||||
if (ta) {
|
||||
const start = ta.selectionStart;
|
||||
const end = ta.selectionEnd;
|
||||
const next = bodyText.slice(0, start) + token + bodyText.slice(end);
|
||||
setBodyText(next);
|
||||
setTimeout(() => {
|
||||
ta.focus();
|
||||
ta.setSelectionRange(start + token.length, start + token.length);
|
||||
}, 0);
|
||||
} else {
|
||||
setBodyText((prev) => prev + "\n" + token);
|
||||
}
|
||||
}
|
||||
|
||||
const preview = {
|
||||
subject: subject || "Your Email Subject",
|
||||
body_text: bodyText,
|
||||
body_html: bodyHtml || `<p style="white-space:pre-wrap">${bodyText}</p>`,
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
setError("");
|
||||
const result = await upsertTemplate({
|
||||
id: template?.id,
|
||||
brand_id: brandId,
|
||||
name,
|
||||
subject,
|
||||
body_text: bodyText,
|
||||
body_html: (htmlMode && bodyHtml) ? bodyHtml : undefined,
|
||||
template_type: templateType,
|
||||
campaign_type: (campaignType as CampaignType) || undefined,
|
||||
});
|
||||
setSaving(false);
|
||||
if (!result.success) {
|
||||
setError(result.error ?? "Failed to save");
|
||||
return;
|
||||
}
|
||||
router.push("/admin/communications/templates");
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-xl font-semibold text-zinc-100">
|
||||
{mode === "new" ? "New Template" : "Edit Template"}
|
||||
</h2>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab("edit")}
|
||||
className={`rounded-lg px-4 py-2 text-sm font-medium ${activeTab === "edit" ? "bg-slate-900 text-white" : "bg-zinc-950 text-zinc-400 hover:bg-slate-200"}`}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab("preview")}
|
||||
className={`rounded-lg px-4 py-2 text-sm font-medium ${activeTab === "preview" ? "bg-slate-900 text-white" : "bg-zinc-950 text-zinc-400 hover:bg-slate-200"}`}
|
||||
>
|
||||
Preview
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded-lg bg-red-900/30 border border-red-200 px-4 py-3 text-sm text-red-400">{error}</div>
|
||||
)}
|
||||
|
||||
{activeTab === "edit" ? (
|
||||
<div className="space-y-6">
|
||||
{/* Basic info */}
|
||||
<div className="bg-zinc-900 rounded-xl border border-zinc-800 p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-slate-800">Template Info</h3>
|
||||
{/* Built-in template picker */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-zinc-500">Start from:</span>
|
||||
<select
|
||||
className="text-sm border border-zinc-600 rounded-lg px-3 py-1.5"
|
||||
onChange={(e) => {
|
||||
const tpl = BUILT_IN_TEMPLATES.find((t) => t.id === e.target.value);
|
||||
if (tpl) applyBuiltIn(tpl);
|
||||
e.target.value = "";
|
||||
}}
|
||||
defaultValue=""
|
||||
>
|
||||
<option value="">— Pre-built template —</option>
|
||||
{BUILT_IN_TEMPLATES.map((t) => (
|
||||
<option key={t.id} value={t.id}>{t.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300 mb-1">Template Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full border border-zinc-600 rounded-lg px-3 py-2 text-sm"
|
||||
placeholder="e.g. Pickup Reminder"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300 mb-1">Template Type *</label>
|
||||
<select
|
||||
value={templateType}
|
||||
onChange={(e) => setTemplateType(e.target.value as TemplateType)}
|
||||
className="w-full border border-zinc-600 rounded-lg px-3 py-2 text-sm"
|
||||
>
|
||||
{TEMPLATE_TYPES.map((t) => (
|
||||
<option key={t.value} value={t.value}>{t.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300 mb-1">Campaign Type</label>
|
||||
<select
|
||||
value={campaignType}
|
||||
onChange={(e) => setCampaignType(e.target.value as CampaignType)}
|
||||
className="w-full border border-zinc-600 rounded-lg px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">Any</option>
|
||||
{CAMPAIGN_TYPES.map((t) => (
|
||||
<option key={t.value} value={t.value}>{t.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Subject */}
|
||||
<div className="bg-zinc-900 rounded-xl border border-zinc-800 p-6 space-y-4">
|
||||
<h3 className="text-sm font-semibold text-slate-800">Subject Line</h3>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300 mb-1">Subject *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
className="w-full border border-zinc-600 rounded-lg px-3 py-2 text-sm"
|
||||
placeholder="Email subject line"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-slate-400">
|
||||
Variables:{" "}
|
||||
{TEMPLATE_VARIABLES.slice(0, 6).map((v) => (
|
||||
<button
|
||||
key={v.key}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const tok = `{{${v.key}}}`;
|
||||
const start = subject.length;
|
||||
setSubject((s) => s + tok);
|
||||
}}
|
||||
className="ml-1 text-blue-400 hover:text-blue-800"
|
||||
>
|
||||
{`{{${v.key}}}`}
|
||||
</button>
|
||||
))}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body editor */}
|
||||
<div className="bg-zinc-900 rounded-xl border border-zinc-800 p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-slate-800">Message Body</h3>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={htmlMode}
|
||||
onChange={(e) => setHtmlMode(e.target.checked)}
|
||||
className="rounded border-slate-400"
|
||||
/>
|
||||
<span className="text-zinc-400">HTML mode</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Variable toolbar */}
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<span className="text-xs text-slate-400 self-center mr-1">Insert:</span>
|
||||
{TEMPLATE_VARIABLES.map((v) => (
|
||||
<button
|
||||
key={v.key}
|
||||
type="button"
|
||||
title={`${v.label}: ${v.example}`}
|
||||
onClick={() => insertVariable(v.key)}
|
||||
className="rounded bg-zinc-950 px-2 py-1 text-xs text-zinc-400 hover:bg-blue-900/40 hover:text-blue-700"
|
||||
>
|
||||
{`{{${v.key}}}`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{htmlMode ? (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300 mb-1">HTML Body</label>
|
||||
<textarea
|
||||
value={bodyHtml}
|
||||
onChange={(e) => setBodyHtml(e.target.value)}
|
||||
rows={12}
|
||||
className="w-full border border-zinc-600 rounded-lg px-3 py-2 text-sm font-mono text-xs"
|
||||
placeholder="<p>HTML version with variables like {{first_name}}...</p>"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300 mb-1">Plain Text Fallback *</label>
|
||||
<textarea
|
||||
id="body-textarea"
|
||||
value={bodyText}
|
||||
onChange={(e) => setBodyText(e.target.value)}
|
||||
rows={6}
|
||||
className="w-full border border-zinc-600 rounded-lg px-3 py-2 text-sm"
|
||||
placeholder="Plain text version..."
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300 mb-1">Body (Plain Text) *</label>
|
||||
<textarea
|
||||
id="body-textarea"
|
||||
value={bodyText}
|
||||
onChange={(e) => setBodyText(e.target.value)}
|
||||
rows={10}
|
||||
className="w-full border border-zinc-600 rounded-lg px-3 py-2 text-sm"
|
||||
placeholder="Message body with variables like {{first_name}}..."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-slate-400">
|
||||
Tip: Click any variable button above to insert it at the cursor position. Variables are replaced when the email is sent.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={saving || !name || !subject || !bodyText}
|
||||
className="inline-flex items-center rounded-lg bg-blue-600 px-5 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{saving ? "Saving..." : "Save Template"}
|
||||
</button>
|
||||
<a href="/admin/communications/templates" className="text-sm text-zinc-500 hover:text-zinc-300 ml-4">
|
||||
Cancel
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Preview Tab */
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-zinc-400">Preview:</span>
|
||||
<div className="flex rounded-lg border border-zinc-600 overflow-hidden">
|
||||
<button
|
||||
onClick={() => setPreviewDevice("desktop")}
|
||||
className={`px-4 py-2 text-sm font-medium ${previewDevice === "desktop" ? "bg-slate-900 text-white" : "bg-zinc-900 text-zinc-400 hover:bg-zinc-900"}`}
|
||||
>
|
||||
Desktop
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPreviewDevice("mobile")}
|
||||
className={`px-4 py-2 text-sm font-medium ${previewDevice === "mobile" ? "bg-slate-900 text-white" : "bg-zinc-900 text-zinc-400 hover:bg-zinc-900"}`}
|
||||
>
|
||||
Mobile
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Email preview */}
|
||||
<div className={`mx-auto transition-all ${previewDevice === "mobile" ? "max-w-[375px]" : "max-w-[600px]"}`}>
|
||||
<div className="bg-zinc-900 rounded-xl border border-zinc-800 shadow-black/20 overflow-hidden">
|
||||
{/* Mock email header */}
|
||||
<div className="bg-zinc-950 border-b border-zinc-800 px-4 py-2 flex items-center gap-2">
|
||||
<div className="flex gap-1.5">
|
||||
<div className="w-3 h-3 rounded-full bg-red-400" />
|
||||
<div className="w-3 h-3 rounded-full bg-yellow-400" />
|
||||
<div className="w-3 h-3 rounded-full bg-green-400" />
|
||||
</div>
|
||||
<div className="flex-1 text-center text-xs text-zinc-500 truncate">
|
||||
{preview.subject}
|
||||
</div>
|
||||
</div>
|
||||
{/* Email body */}
|
||||
<div
|
||||
className="overflow-hidden"
|
||||
dangerouslySetInnerHTML={{ __html: preview.body_html }}
|
||||
/>
|
||||
{/* Plain text toggle */}
|
||||
{bodyText && (
|
||||
<details className="border-t border-zinc-800">
|
||||
<summary className="px-4 py-2 text-xs text-zinc-500 cursor-pointer hover:text-zinc-300">
|
||||
View plain text version
|
||||
</summary>
|
||||
<pre className="px-4 pb-4 text-xs text-zinc-400 whitespace-pre-wrap">{preview.body_text}</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import Link from "next/link";
|
||||
import { getTimeTrackingWorkers, getTimeTrackingTasks, getWorkerTimeLogs, getTimeTrackingSummary, type TimeWorker, type TimeTask, type TimeLog, type TimeSummary } from "@/actions/time-tracking";
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
|
||||
}
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
return new Date(iso).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", hour12: true });
|
||||
}
|
||||
|
||||
function formatHours(h: number): string {
|
||||
return h < 1 ? `${(h * 60).toFixed(0)}m` : `${h.toFixed(1)}h`;
|
||||
}
|
||||
|
||||
function formatMinutes(min: number): string {
|
||||
const h = Math.floor(min / 60);
|
||||
const m = min % 60;
|
||||
if (h === 0) return `${m}m`;
|
||||
if (m === 0) return `${h}h`;
|
||||
return `${h}h ${m}m`;
|
||||
}
|
||||
|
||||
export default function TimeTrackingAdminPanel({ brandId }: { brandId?: string }) {
|
||||
const [workers, setWorkers] = useState<TimeWorker[]>([]);
|
||||
const [tasks, setTasks] = useState<TimeTask[]>([]);
|
||||
const [summary, setSummary] = useState<TimeSummary | null>(null);
|
||||
const [logs, setLogs] = useState<TimeLog[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const [dateRange, setDateRange] = useState<{ start: string; end: string }>(() => {
|
||||
const now = new Date();
|
||||
const start = new Date(now.getFullYear(), now.getMonth(), 1).toISOString().slice(0, 10);
|
||||
const end = now.toISOString().slice(0, 10);
|
||||
return { start, end };
|
||||
});
|
||||
const [selectedWorker, setSelectedWorker] = useState("");
|
||||
const [selectedTask, setSelectedTask] = useState("");
|
||||
const [tab, setTab] = useState<"summary" | "logs">("summary");
|
||||
const [logPage, setLogPage] = useState(0);
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!brandId) return;
|
||||
setLoading(true);
|
||||
const [w, t, s, l] = await Promise.all([
|
||||
getTimeTrackingWorkers(brandId),
|
||||
getTimeTrackingTasks(brandId, false),
|
||||
getTimeTrackingSummary(brandId, dateRange.start, dateRange.end),
|
||||
getWorkerTimeLogs(brandId, {
|
||||
workerId: selectedWorker || undefined,
|
||||
taskId: selectedTask || undefined,
|
||||
start: dateRange.start,
|
||||
end: dateRange.end,
|
||||
limit: PAGE_SIZE,
|
||||
offset: logPage * PAGE_SIZE,
|
||||
}),
|
||||
]);
|
||||
setWorkers(w);
|
||||
setTasks(t);
|
||||
setSummary(s);
|
||||
setLogs(l);
|
||||
setLoading(false);
|
||||
}, [brandId, dateRange, selectedWorker, selectedTask, logPage]);
|
||||
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- Data fetching pattern
|
||||
useEffect(() => { load(); }, [load]);
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- Reset pagination on filter change
|
||||
useEffect(() => { setLogPage(0); }, [dateRange, selectedWorker, selectedTask]);
|
||||
|
||||
if (!brandId) {
|
||||
return (
|
||||
<div className="text-center py-20 text-stone-500">
|
||||
<p className="text-sm">Select a brand to view time tracking.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-stone-950 tracking-tight">Time Tracking</h1>
|
||||
<p className="text-sm text-stone-500 mt-0.5">
|
||||
{dateRange.start} — {dateRange.end}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
href="/admin/time-tracking/settings?tab=export"
|
||||
className="flex items-center gap-2 text-sm text-stone-600 hover:text-stone-900 border border-stone-200 hover:border-stone-300 bg-white hover:bg-stone-50 rounded-xl px-4 py-2 transition-all shadow-sm"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
Export CSV
|
||||
</Link>
|
||||
<Link
|
||||
href="/admin/time-tracking/settings"
|
||||
className="flex items-center gap-2 text-sm text-stone-600 hover:text-stone-900 border border-stone-200 hover:border-stone-300 bg-white hover:bg-stone-50 rounded-xl px-4 py-2 transition-all shadow-sm"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
Settings
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="rounded-xl border border-stone-200 bg-white p-4 shadow-sm">
|
||||
<div className="flex flex-wrap gap-4 items-end">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-stone-500 font-medium">From</label>
|
||||
<input
|
||||
type="date"
|
||||
value={dateRange.start}
|
||||
onChange={e => setDateRange(r => ({ ...r, start: e.target.value }))}
|
||||
className="px-3 py-2 rounded-lg border border-stone-200 bg-stone-50 text-stone-900 text-sm focus:outline-none focus:border-emerald-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-stone-500 font-medium">To</label>
|
||||
<input
|
||||
type="date"
|
||||
value={dateRange.end}
|
||||
onChange={e => setDateRange(r => ({ ...r, end: e.target.value }))}
|
||||
className="px-3 py-2 rounded-lg border border-stone-200 bg-stone-50 text-stone-900 text-sm focus:outline-none focus:border-emerald-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-stone-500 font-medium">Worker</label>
|
||||
<select
|
||||
value={selectedWorker}
|
||||
onChange={e => setSelectedWorker(e.target.value)}
|
||||
className="px-3 py-2 rounded-lg border border-stone-200 bg-stone-50 text-stone-900 text-sm focus:outline-none focus:border-emerald-500 transition-colors min-w-[160px]"
|
||||
>
|
||||
<option value="">All Workers</option>
|
||||
{workers.map(w => (
|
||||
<option key={w.id} value={w.id}>{w.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-stone-500 font-medium">Task</label>
|
||||
<select
|
||||
value={selectedTask}
|
||||
onChange={e => setSelectedTask(e.target.value)}
|
||||
className="px-3 py-2 rounded-lg border border-stone-200 bg-stone-50 text-stone-900 text-sm focus:outline-none focus:border-emerald-500 transition-colors min-w-[160px]"
|
||||
>
|
||||
<option value="">All Tasks</option>
|
||||
{tasks.map(t => (
|
||||
<option key={t.id} value={t.id}>{t.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
onClick={load}
|
||||
className="px-4 py-2 rounded-lg bg-emerald-600 hover:bg-emerald-500 text-white text-sm font-semibold transition-all shadow-sm"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 border-b border-stone-200">
|
||||
<button
|
||||
onClick={() => setTab("summary")}
|
||||
className={`px-4 py-2.5 text-sm font-semibold transition-all border-b-2 -mb-px ${tab === "summary" ? "text-emerald-600 border-emerald-600" : "text-stone-500 border-transparent hover:text-stone-700 hover:border-stone-300"}`}
|
||||
>
|
||||
Summary
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTab("logs")}
|
||||
className={`px-4 py-2.5 text-sm font-semibold transition-all border-b-2 -mb-px ${tab === "logs" ? "text-emerald-600 border-emerald-600" : "text-stone-500 border-transparent hover:text-stone-700 hover:border-stone-300"}`}
|
||||
>
|
||||
All Logs ({loading ? "…" : logs.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-16 text-stone-500 text-sm">Loading...</div>
|
||||
) : tab === "summary" && summary ? (
|
||||
<div className="space-y-6">
|
||||
{/* Totals */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="rounded-2xl border border-stone-200 bg-white p-5 shadow-sm">
|
||||
<p className="text-xs text-stone-500 uppercase tracking-widest mb-2">Total Hours</p>
|
||||
<p className="text-3xl font-black text-stone-950">{formatHours(summary.totals.total_hours)}</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-stone-200 bg-white p-5 shadow-sm">
|
||||
<p className="text-xs text-stone-500 uppercase tracking-widest mb-2">Entries</p>
|
||||
<p className="text-3xl font-black text-stone-950">{summary.totals.entry_count}</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-stone-200 bg-white p-5 shadow-sm">
|
||||
<p className="text-xs text-stone-500 uppercase tracking-widest mb-2">Open Clock-ins</p>
|
||||
<p className="text-3xl font-black text-amber-600">{summary.totals.open_count}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* By Worker */}
|
||||
<div className="rounded-2xl border border-stone-200 bg-white overflow-hidden shadow-sm">
|
||||
<div className="px-5 py-4 border-b border-stone-100">
|
||||
<h3 className="text-sm font-bold text-stone-950">By Worker</h3>
|
||||
</div>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-xs text-stone-500 uppercase tracking-widest bg-stone-50">
|
||||
<th className="text-left px-5 py-3 font-medium">Worker</th>
|
||||
<th className="text-right px-5 py-3 font-medium">Entries</th>
|
||||
<th className="text-right px-5 py-3 font-medium">Total Hours</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(summary.by_worker ?? []).length === 0 ? (
|
||||
<tr><td colSpan={3} className="px-5 py-8 text-center text-stone-400">No data</td></tr>
|
||||
) : (summary.by_worker ?? []).map(w => (
|
||||
<tr key={w.id} className="border-t border-stone-100 hover:bg-stone-50 transition-colors">
|
||||
<td className="px-5 py-3.5 text-stone-900 font-medium">{w.name}</td>
|
||||
<td className="px-5 py-3.5 text-stone-600 text-right">{w.entry_count}</td>
|
||||
<td className="px-5 py-3.5 text-stone-900 text-right font-mono">{formatHours(w.total_hours)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* By Task */}
|
||||
<div className="rounded-2xl border border-stone-200 bg-white overflow-hidden shadow-sm">
|
||||
<div className="px-5 py-4 border-b border-stone-100">
|
||||
<h3 className="text-sm font-bold text-stone-950">By Task</h3>
|
||||
</div>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-xs text-stone-500 uppercase tracking-widest bg-stone-50">
|
||||
<th className="text-left px-5 py-3 font-medium">Task</th>
|
||||
<th className="text-right px-5 py-3 font-medium">Entries</th>
|
||||
<th className="text-right px-5 py-3 font-medium">Total Hours</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(summary.by_task ?? []).length === 0 ? (
|
||||
<tr><td colSpan={3} className="px-5 py-8 text-center text-stone-400">No data</td></tr>
|
||||
) : (summary.by_task ?? []).map(t => (
|
||||
<tr key={t.id} className="border-t border-stone-100 hover:bg-stone-50 transition-colors">
|
||||
<td className="px-5 py-3.5 text-stone-900 font-medium">{t.name}</td>
|
||||
<td className="px-5 py-3.5 text-stone-600 text-right">{t.entry_count}</td>
|
||||
<td className="px-5 py-3.5 text-stone-900 text-right font-mono">{formatHours(t.total_hours)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Logs tab */
|
||||
<div className="rounded-2xl border border-stone-200 bg-white overflow-hidden shadow-sm">
|
||||
{/* Pagination header */}
|
||||
<div className="flex items-center justify-between px-5 py-3 border-b border-stone-200 bg-stone-50">
|
||||
<p className="text-xs text-stone-500">
|
||||
{logs.length === PAGE_SIZE
|
||||
? `Showing page ${logPage + 1}`
|
||||
: `${logs.length} entries`}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setLogPage(p => Math.max(0, p - 1))}
|
||||
disabled={logPage === 0}
|
||||
className="flex h-7 w-7 items-center justify-center rounded border border-stone-200 text-stone-500 hover:text-stone-700 hover:bg-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<span className="text-xs text-stone-500 px-1">Page {logPage + 1}</span>
|
||||
<button
|
||||
onClick={() => setLogPage(p => p + 1)}
|
||||
disabled={logs.length < PAGE_SIZE}
|
||||
className="flex h-7 w-7 items-center justify-center rounded border border-stone-200 text-stone-500 hover:text-stone-700 hover:bg-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-xs text-stone-500 uppercase tracking-widest border-b border-stone-200 bg-stone-50">
|
||||
<th className="text-left px-5 py-3 font-medium">Worker</th>
|
||||
<th className="text-left px-5 py-3 font-medium">Task</th>
|
||||
<th className="text-left px-5 py-3 font-medium">Clock In</th>
|
||||
<th className="text-left px-5 py-3 font-medium">Clock Out</th>
|
||||
<th className="text-right px-5 py-3 font-medium">Lunch</th>
|
||||
<th className="text-right px-5 py-3 font-medium">Total</th>
|
||||
<th className="text-left px-5 py-3 font-medium">Notes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{logs.length === 0 ? (
|
||||
<tr><td colSpan={7} className="px-5 py-12 text-center text-stone-400">No entries found</td></tr>
|
||||
) : logs.map(log => (
|
||||
<tr key={log.id} className="border-t border-stone-100 hover:bg-stone-50 transition-colors">
|
||||
<td className="px-5 py-3.5 text-stone-900 font-medium">{log.worker_name}</td>
|
||||
<td className="px-5 py-3.5 text-stone-600">{log.task_name}</td>
|
||||
<td className="px-5 py-3.5 text-stone-600 font-mono text-xs">
|
||||
<span>{formatDate(log.clock_in)}</span>
|
||||
<span className="ml-2 text-stone-400">{formatTime(log.clock_in)}</span>
|
||||
</td>
|
||||
<td className="px-5 py-3.5 font-mono text-xs">
|
||||
{log.clock_out ? (
|
||||
<>
|
||||
<span className="text-stone-600">{formatDate(log.clock_out)}</span>
|
||||
<span className="ml-2 text-stone-400">{formatTime(log.clock_out)}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-amber-600 font-semibold">OPEN</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-5 py-3.5 text-stone-600 text-right">{log.lunch_break_minutes > 0 ? `${log.lunch_break_minutes}m` : "—"}</td>
|
||||
<td className="px-5 py-3.5 text-stone-900 text-right font-mono">{formatMinutes(log.total_minutes)}</td>
|
||||
<td className="px-5 py-3.5 text-stone-500 text-xs max-w-[160px] truncate">{log.notes ?? ""}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,766 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
getTimeTrackingWorkers,
|
||||
getTimeTrackingTasks,
|
||||
createTimeWorker,
|
||||
resetTimeWorkerPin,
|
||||
updateTimeWorker,
|
||||
deleteTimeWorker,
|
||||
createTimeTask,
|
||||
updateTimeTask,
|
||||
deleteTimeTask,
|
||||
getTimeTrackingSettings,
|
||||
updateTimeTrackingSettings,
|
||||
getTimeTrackingNotificationLog,
|
||||
type TimeWorker,
|
||||
type TimeTask,
|
||||
type TimeTrackingSettings,
|
||||
type NotificationLogEntry,
|
||||
} from "@/actions/time-tracking";
|
||||
|
||||
type Tab = "dashboard" | "settings";
|
||||
|
||||
const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
||||
|
||||
interface TimeTrackingSettingsClientProps {
|
||||
brandId: string;
|
||||
}
|
||||
|
||||
export default function TimeTrackingSettingsClient({ brandId }: TimeTrackingSettingsClientProps) {
|
||||
const [tab, setTab] = useState<Tab>("dashboard");
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const t = params.get("tab");
|
||||
if (t === "settings" || t === "export") setTab(t as Tab);
|
||||
}, []);
|
||||
|
||||
const [workers, setWorkers] = useState<TimeWorker[]>([]);
|
||||
const [tasks, setTasks] = useState<TimeTask[]>([]);
|
||||
const [settings, setSettings] = useState<TimeTrackingSettings | null>(null);
|
||||
const [notificationLog, setNotificationLog] = useState<NotificationLogEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const [payPeriodStartDay, setPayPeriodStartDay] = useState(0);
|
||||
const [payPeriodLength, setPayPeriodLength] = useState(7);
|
||||
const [dailyOvertimeThreshold, setDailyOvertimeThreshold] = useState(12);
|
||||
const [weeklyOvertimeThreshold, setWeeklyOvertimeThreshold] = useState(56);
|
||||
const [overtimeMultiplier, setOvertimeMultiplier] = useState(1.5);
|
||||
const [overtimeNotifications, setOvertimeNotifications] = useState(true);
|
||||
const [settingsSaving, setSettingsSaving] = useState(false);
|
||||
const [settingsSaved, setSettingsSaved] = useState(false);
|
||||
const [settingsError, setSettingsError] = useState<string | null>(null);
|
||||
|
||||
const [notificationEmails, setNotificationEmails] = useState<string[]>([]);
|
||||
const [notificationSmsNumbers, setNotificationSmsNumbers] = useState<string[]>([]);
|
||||
const [enableDailyAlerts, setEnableDailyAlerts] = useState(true);
|
||||
const [enableWeeklyAlerts, setEnableWeeklyAlerts] = useState(true);
|
||||
const [dailyAlertThreshold, setDailyAlertThreshold] = useState(80);
|
||||
const [weeklyAlertThreshold, setWeeklyAlertThreshold] = useState(80);
|
||||
const [sendEndOfPeriodSummary, setSendEndOfPeriodSummary] = useState(true);
|
||||
const [brandName, setBrandName] = useState("Farm");
|
||||
const [newEmail, setNewEmail] = useState("");
|
||||
const [newSms, setNewSms] = useState("");
|
||||
|
||||
const [showWorkerModal, setShowWorkerModal] = useState(false);
|
||||
const [editingWorker, setEditingWorker] = useState<TimeWorker | null>(null);
|
||||
const [workerName, setWorkerName] = useState("");
|
||||
const [workerRole, setWorkerRole] = useState("worker");
|
||||
const [workerLang, setWorkerLang] = useState("en");
|
||||
const [workerActive, setWorkerActive] = useState(true);
|
||||
const [resetPinResult, setResetPinResult] = useState<string | null>(null);
|
||||
const [workerError, setWorkerError] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const [showTaskModal, setShowTaskModal] = useState(false);
|
||||
const [editingTask, setEditingTask] = useState<TimeTask | null>(null);
|
||||
const [taskName, setTaskName] = useState("");
|
||||
const [taskNameEs, setTaskNameEs] = useState("");
|
||||
const [taskUnit, setTaskUnit] = useState("hours");
|
||||
const [taskActive, setTaskActive] = useState(true);
|
||||
const [taskSortOrder, setTaskSortOrder] = useState(0);
|
||||
const [taskError, setTaskError] = useState<string | null>(null);
|
||||
|
||||
const [exportStart, setExportStart] = useState("");
|
||||
const [exportEnd, setExportEnd] = useState("");
|
||||
const [exportBrand, setExportBrand] = useState<"all" | "tuxedo" | "ird">("all");
|
||||
const [includeOvertime, setIncludeOvertime] = useState(true);
|
||||
const [includeNotes, setIncludeNotes] = useState(true);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
const [w, t, s] = await Promise.all([
|
||||
getTimeTrackingWorkers(brandId),
|
||||
getTimeTrackingTasks(brandId, false),
|
||||
getTimeTrackingSettings(brandId),
|
||||
]);
|
||||
setWorkers(w);
|
||||
setTasks(t);
|
||||
if (s) {
|
||||
setSettings(s);
|
||||
setPayPeriodStartDay(s.pay_period_start_day);
|
||||
setPayPeriodLength(s.pay_period_length_days);
|
||||
setDailyOvertimeThreshold(s.daily_overtime_threshold);
|
||||
setWeeklyOvertimeThreshold(s.weekly_overtime_threshold);
|
||||
setOvertimeMultiplier(s.overtime_multiplier);
|
||||
setOvertimeNotifications(s.overtime_notifications);
|
||||
setNotificationEmails(s.notification_emails ?? []);
|
||||
setNotificationSmsNumbers(s.notification_sms_numbers ?? []);
|
||||
setEnableDailyAlerts(s.enable_daily_alerts ?? true);
|
||||
setEnableWeeklyAlerts(s.enable_weekly_alerts ?? true);
|
||||
setDailyAlertThreshold(Math.round((s.daily_alert_threshold ?? 0.80) * 100));
|
||||
setWeeklyAlertThreshold(Math.round((s.weekly_alert_threshold ?? 0.80) * 100));
|
||||
setSendEndOfPeriodSummary(s.send_end_of_period_summary ?? true);
|
||||
setBrandName(s.brand_name ?? "Farm");
|
||||
}
|
||||
const log = await getTimeTrackingNotificationLog(brandId, 50);
|
||||
setNotificationLog(log);
|
||||
setLoading(false);
|
||||
}, [brandId]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const handleSaveNotifications = async () => {
|
||||
setSettingsSaving(true);
|
||||
setSettingsError(null);
|
||||
setSettingsSaved(false);
|
||||
const result = await updateTimeTrackingSettings(brandId, {
|
||||
pay_period_start_day: payPeriodStartDay,
|
||||
pay_period_length_days: payPeriodLength,
|
||||
daily_overtime_threshold: dailyOvertimeThreshold,
|
||||
weekly_overtime_threshold: weeklyOvertimeThreshold,
|
||||
overtime_multiplier: overtimeMultiplier,
|
||||
overtime_notifications: overtimeNotifications,
|
||||
notification_emails: notificationEmails,
|
||||
notification_sms_numbers: notificationSmsNumbers,
|
||||
enable_daily_alerts: enableDailyAlerts,
|
||||
enable_weekly_alerts: enableWeeklyAlerts,
|
||||
daily_alert_threshold: dailyAlertThreshold / 100,
|
||||
weekly_alert_threshold: weeklyAlertThreshold / 100,
|
||||
send_end_of_period_summary: sendEndOfPeriodSummary,
|
||||
brand_name: brandName,
|
||||
});
|
||||
setSettingsSaving(false);
|
||||
if (!result.success) { setSettingsError(result.error ?? "Failed to save"); return; }
|
||||
setSettingsSaved(true);
|
||||
setTimeout(() => setSettingsSaved(false), 3000);
|
||||
load();
|
||||
};
|
||||
|
||||
const addEmail = () => {
|
||||
const email = newEmail.trim();
|
||||
if (email && !notificationEmails.includes(email)) {
|
||||
setNotificationEmails([...notificationEmails, email]);
|
||||
}
|
||||
setNewEmail("");
|
||||
};
|
||||
const removeEmail = (email: string) => setNotificationEmails(notificationEmails.filter(e => e !== email));
|
||||
const addSms = () => {
|
||||
const num = newSms.replace(/[^0-9+]/g, "");
|
||||
if (num && !notificationSmsNumbers.includes(num)) {
|
||||
setNotificationSmsNumbers([...notificationSmsNumbers, num]);
|
||||
}
|
||||
setNewSms("");
|
||||
};
|
||||
const removeSms = (num: string) => setNotificationSmsNumbers(notificationSmsNumbers.filter(n => n !== num));
|
||||
|
||||
function buildExportUrl(type: "quickbooks" | "payroll" | "detailed" | "summary") {
|
||||
const params = new URLSearchParams({
|
||||
type,
|
||||
start: exportStart,
|
||||
end: exportEnd,
|
||||
brand: exportBrand,
|
||||
overtime: String(includeOvertime),
|
||||
notes: String(includeNotes),
|
||||
});
|
||||
return `/api/time-tracking/export?${params}`;
|
||||
}
|
||||
|
||||
function triggerDownload(url: string) {
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
}
|
||||
|
||||
const openAddWorker = () => {
|
||||
setEditingWorker(null);
|
||||
setWorkerName(""); setWorkerRole("worker"); setWorkerLang("en");
|
||||
setWorkerActive(true); setWorkerError(null); setResetPinResult(null);
|
||||
setShowWorkerModal(true);
|
||||
};
|
||||
|
||||
const openEditWorker = (w: TimeWorker) => {
|
||||
setEditingWorker(w); setWorkerName(w.name); setWorkerRole(w.role);
|
||||
setWorkerLang(w.lang); setWorkerActive(w.active);
|
||||
setWorkerError(null); setResetPinResult(null);
|
||||
setShowWorkerModal(true);
|
||||
};
|
||||
|
||||
const handleSaveWorker = async () => {
|
||||
if (!workerName.trim()) { setWorkerError("Name is required"); return; }
|
||||
setSubmitting(true); setWorkerError(null);
|
||||
if (editingWorker) {
|
||||
const result = await updateTimeWorker(editingWorker.id, workerName.trim(), workerRole, workerLang, workerActive);
|
||||
if (!result.success) { setWorkerError(result.error ?? "Failed"); setSubmitting(false); return; }
|
||||
} else {
|
||||
const result = await createTimeWorker(brandId, workerName.trim(), workerRole, workerLang);
|
||||
if (!result.success) { setWorkerError(result.error ?? "Failed"); setSubmitting(false); return; }
|
||||
}
|
||||
setSubmitting(false); setShowWorkerModal(false); load();
|
||||
};
|
||||
|
||||
const handleResetPin = async (workerId: string) => {
|
||||
const result = await resetTimeWorkerPin(workerId);
|
||||
if (result.success && result.pin) setResetPinResult(result.pin);
|
||||
else setWorkerError(result.error ?? "Failed to reset PIN");
|
||||
};
|
||||
|
||||
const handleDeleteWorker = async (workerId: string) => {
|
||||
if (!confirm("Delete this worker? This cannot be undone.")) return;
|
||||
const result = await deleteTimeWorker(workerId);
|
||||
if (!result.success) { setWorkerError(result.error ?? "Failed"); return; }
|
||||
load();
|
||||
};
|
||||
|
||||
const openAddTask = () => {
|
||||
setEditingTask(null); setTaskName(""); setTaskNameEs("");
|
||||
setTaskUnit("hours"); setTaskActive(true); setTaskSortOrder(0);
|
||||
setTaskError(null); setShowTaskModal(true);
|
||||
};
|
||||
|
||||
const openEditTask = (t: TimeTask) => {
|
||||
setEditingTask(t); setTaskName(t.name); setTaskNameEs(t.name_es ?? "");
|
||||
setTaskUnit(t.unit); setTaskActive(t.active); setTaskSortOrder(t.sort_order);
|
||||
setTaskError(null); setShowTaskModal(true);
|
||||
};
|
||||
|
||||
const handleSaveTask = async () => {
|
||||
if (!taskName.trim()) { setTaskError("Name is required"); return; }
|
||||
setSubmitting(true); setTaskError(null);
|
||||
if (editingTask) {
|
||||
const result = await updateTimeTask(editingTask.id, taskName.trim(), taskNameEs, taskUnit, taskActive, taskSortOrder);
|
||||
if (!result.success) { setTaskError(result.error ?? "Failed"); setSubmitting(false); return; }
|
||||
} else {
|
||||
const result = await createTimeTask(brandId, taskName.trim(), taskNameEs || null, taskUnit, taskSortOrder);
|
||||
if (!result.success) { setTaskError(result.error ?? "Failed"); setSubmitting(false); return; }
|
||||
}
|
||||
setSubmitting(false); setShowTaskModal(false); load();
|
||||
};
|
||||
|
||||
const handleDeleteTask = async (taskId: string) => {
|
||||
if (!confirm("Delete this task?")) return;
|
||||
const result = await deleteTimeTask(taskId);
|
||||
if (!result.success) { setTaskError(result.error ?? "Failed"); return; }
|
||||
load();
|
||||
};
|
||||
|
||||
const triggerLabel: Record<string, { en: string; color: string }> = {
|
||||
daily_approaching: { en: "Daily OT Approaching", color: "bg-amber-100 text-amber-700" },
|
||||
daily_reached: { en: "Daily OT Reached", color: "bg-red-100 text-red-700" },
|
||||
weekly_approaching: { en: "Weekly OT Approaching", color: "bg-amber-100 text-amber-700" },
|
||||
weekly_reached: { en: "Weekly OT Reached", color: "bg-red-100 text-red-700" },
|
||||
end_of_period_summary:{ en: "Period Summary", color: "bg-emerald-100 text-emerald-700" },
|
||||
};
|
||||
|
||||
if (loading) return <div className="text-center py-16 text-stone-500 text-sm">Loading...</div>;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Page header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-stone-900">Time Tracking</h2>
|
||||
<p className="text-sm text-stone-500 mt-0.5">
|
||||
{tab === "dashboard" ? "Summary, exports & recent activity" : "Workers, tasks & overtime rules"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Two-tab nav */}
|
||||
<div className="flex gap-1 border-b border-stone-200">
|
||||
<button onClick={() => setTab("dashboard")} className={`px-4 py-2 text-sm font-semibold transition-all border-b-2 -mb-px ${tab === "dashboard" ? "text-stone-900 border-stone-900" : "text-stone-500 border-transparent hover:text-stone-700"}`}>
|
||||
Dashboard
|
||||
</button>
|
||||
<button onClick={() => setTab("settings")} className={`px-4 py-2 text-sm font-semibold transition-all border-b-2 -mb-px ${tab === "settings" ? "text-stone-900 border-stone-900" : "text-stone-500 border-transparent hover:text-stone-700"}`}>
|
||||
Settings
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Dashboard tab */}
|
||||
{tab === "dashboard" && (
|
||||
<div className="space-y-6">
|
||||
{/* Stat row */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="bg-white border border-stone-200 rounded-xl p-5 text-center shadow-sm">
|
||||
<p className="text-3xl font-bold text-stone-900">{workers.length}</p>
|
||||
<p className="text-xs text-stone-500 uppercase tracking-widest mt-1">Workers</p>
|
||||
</div>
|
||||
<div className="bg-white border border-stone-200 rounded-xl p-5 text-center shadow-sm">
|
||||
<p className="text-3xl font-bold text-stone-900">{tasks.length}</p>
|
||||
<p className="text-xs text-stone-500 uppercase tracking-widest mt-1">Tasks</p>
|
||||
</div>
|
||||
<div className="bg-white border border-stone-200 rounded-xl p-5 text-center shadow-sm">
|
||||
<p className="text-3xl font-bold text-amber-600">{notificationLog.filter(n => n.email_sent || n.sms_sent).length}</p>
|
||||
<p className="text-xs text-stone-500 uppercase tracking-widest mt-1">Alerts Sent</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick export section */}
|
||||
<div className="bg-white border border-stone-200 rounded-xl p-6 space-y-4 shadow-sm">
|
||||
<h3 className="text-sm font-semibold text-stone-800">Export Data</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button onClick={() => triggerDownload(buildExportUrl("quickbooks"))}
|
||||
className="flex items-center gap-3 px-4 py-3 rounded-xl bg-stone-50 border border-stone-200 hover:border-emerald-500 transition-all text-left">
|
||||
<span className="text-lg">📒</span>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-stone-800">QuickBooks</p>
|
||||
<p className="text-xs text-stone-500">Time import CSV</p>
|
||||
</div>
|
||||
</button>
|
||||
<button onClick={() => triggerDownload(buildExportUrl("payroll"))}
|
||||
className="flex items-center gap-3 px-4 py-3 rounded-xl bg-stone-50 border border-stone-200 hover:border-blue-500 transition-all text-left">
|
||||
<span className="text-lg">💰</span>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-stone-800">Payroll</p>
|
||||
<p className="text-xs text-stone-500">ADP / Gusto / Generic</p>
|
||||
</div>
|
||||
</button>
|
||||
<button onClick={() => triggerDownload(buildExportUrl("detailed"))}
|
||||
className="flex items-center gap-3 px-4 py-3 rounded-xl bg-stone-50 border border-stone-200 hover:border-violet-500 transition-all text-left">
|
||||
<span className="text-lg">📋</span>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-stone-800">Detailed Log</p>
|
||||
<p className="text-xs text-stone-500">Full audit report</p>
|
||||
</div>
|
||||
</button>
|
||||
<button onClick={() => triggerDownload(buildExportUrl("summary"))}
|
||||
className="flex items-center gap-3 px-4 py-3 rounded-xl bg-stone-50 border border-stone-200 hover:border-amber-500 transition-all text-left">
|
||||
<span className="text-lg">📊</span>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-stone-800">Period Summary</p>
|
||||
<p className="text-xs text-stone-500">Per-worker totals</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs text-stone-500">
|
||||
<span>From:</span>
|
||||
<input type="date" value={exportStart} onChange={e => setExportStart(e.target.value)}
|
||||
className="px-3 py-1.5 rounded-lg border border-stone-200 bg-white text-stone-700 focus:outline-none focus:border-emerald-500" />
|
||||
<span>To:</span>
|
||||
<input type="date" value={exportEnd} onChange={e => setExportEnd(e.target.value)}
|
||||
className="px-3 py-1.5 rounded-lg border border-stone-200 bg-white text-stone-700 focus:outline-none focus:border-emerald-500" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notification log */}
|
||||
<div className="bg-white border border-stone-200 rounded-xl overflow-hidden shadow-sm">
|
||||
<div className="px-5 py-4 border-b border-stone-100 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-stone-800">Recent Alerts</h3>
|
||||
</div>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-xs text-stone-500 uppercase tracking-widest border-b border-stone-100">
|
||||
<th className="text-left px-5 py-3 font-medium">Time</th>
|
||||
<th className="text-left px-5 py-3 font-medium">Worker</th>
|
||||
<th className="text-left px-5 py-3 font-medium">Alert</th>
|
||||
<th className="text-left px-5 py-3 font-medium">Email</th>
|
||||
<th className="text-left px-5 py-3 font-medium">SMS</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{notificationLog.length === 0 ? (
|
||||
<tr><td colSpan={5} className="px-5 py-8 text-center text-stone-400">No alerts sent yet</td></tr>
|
||||
) : notificationLog.slice(0, 20).map(entry => {
|
||||
const meta = triggerLabel[entry.trigger_type] ?? { en: entry.trigger_type, color: "bg-stone-100 text-stone-500" };
|
||||
return (
|
||||
<tr key={entry.id} className="border-t border-stone-100 hover:bg-stone-50 transition-colors">
|
||||
<td className="px-5 py-3.5 text-stone-500 text-xs">{new Date(entry.created_at).toLocaleString()}</td>
|
||||
<td className="px-5 py-3.5 text-stone-800 font-medium">{entry.worker_name ?? "—"}</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold ${meta.color}`}>{meta.en}</span>
|
||||
</td>
|
||||
<td className="px-5 py-3.5 text-center">
|
||||
<span className={`text-sm font-bold ${entry.email_sent ? "text-emerald-600" : "text-stone-300"}`}>{entry.email_sent ? "✓" : "—"}</span>
|
||||
</td>
|
||||
<td className="px-5 py-3.5 text-center">
|
||||
<span className={`text-sm font-bold ${entry.sms_sent ? "text-emerald-600" : "text-stone-300"}`}>{entry.sms_sent ? "✓" : "—"}</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Settings tab */}
|
||||
{tab === "settings" && (
|
||||
<div className="space-y-6">
|
||||
{/* Workers section */}
|
||||
<div className="bg-white border border-stone-200 rounded-xl overflow-hidden shadow-sm">
|
||||
<div className="px-5 py-4 border-b border-stone-100 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-stone-800">Workers & PINs</h3>
|
||||
<button onClick={() => { setEditingWorker(null); setWorkerName(""); setWorkerRole("worker"); setWorkerLang("en"); setWorkerActive(true); setShowWorkerModal(true); setWorkerError(null); setResetPinResult(null); }}
|
||||
className="text-xs bg-emerald-600 hover:bg-emerald-500 text-white px-3 py-1.5 rounded-lg font-semibold transition-all">+ Add Worker</button>
|
||||
</div>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-xs text-stone-500 uppercase tracking-widest border-b border-stone-100">
|
||||
<th className="text-left px-5 py-3 font-medium">Name</th>
|
||||
<th className="text-left px-5 py-3 font-medium">Role</th>
|
||||
<th className="text-left px-5 py-3 font-medium">Lang</th>
|
||||
<th className="text-left px-5 py-3 font-medium">Status</th>
|
||||
<th className="text-left px-5 py-3 font-medium">Last Used</th>
|
||||
<th className="text-right px-5 py-3 font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{workers.length === 0 ? (
|
||||
<tr><td colSpan={6} className="px-5 py-12 text-center text-stone-400">No workers yet</td></tr>
|
||||
) : workers.map(w => (
|
||||
<tr key={w.id} className="border-t border-stone-100 hover:bg-stone-50 transition-colors">
|
||||
<td className="px-5 py-3.5 text-stone-800 font-medium">{w.name}</td>
|
||||
<td className="px-5 py-3.5 text-stone-500 capitalize">{w.role}</td>
|
||||
<td className="px-5 py-3.5 text-stone-400 uppercase text-xs font-mono">{w.lang}</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold ${w.active ? "bg-emerald-100 text-emerald-700" : "bg-stone-100 text-stone-500"}`}>
|
||||
{w.active ? "Active" : "Inactive"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-3.5 text-stone-400 text-xs">{w.last_used_at ? new Date(w.last_used_at).toLocaleDateString() : "—"}</td>
|
||||
<td className="px-5 py-3.5 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button onClick={() => openEditWorker(w)} className="text-xs text-stone-500 hover:text-stone-800 px-2 py-1 rounded-lg hover:bg-stone-100 transition-all">Edit</button>
|
||||
<button onClick={() => handleResetPin(w.id)} className="text-xs text-amber-600 hover:text-amber-800 px-2 py-1 rounded-lg hover:bg-amber-50 transition-all">Reset PIN</button>
|
||||
<button onClick={() => handleDeleteWorker(w.id)} className="text-xs text-red-500 hover:text-red-700 px-2 py-1 rounded-lg hover:bg-red-50 transition-all">Delete</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Tasks section */}
|
||||
<div className="bg-white border border-stone-200 rounded-xl overflow-hidden shadow-sm">
|
||||
<div className="px-5 py-4 border-b border-stone-100 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-stone-800">Tasks</h3>
|
||||
<button onClick={() => { setEditingTask(null); setTaskName(""); setTaskNameEs(""); setTaskUnit("hours"); setTaskSortOrder(0); setTaskActive(true); setShowTaskModal(true); setTaskError(null); }}
|
||||
className="text-xs bg-amber-500 hover:bg-amber-600 text-white px-3 py-1.5 rounded-lg font-semibold transition-all">+ Add Task</button>
|
||||
</div>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-xs text-stone-500 uppercase tracking-widest border-b border-stone-100">
|
||||
<th className="text-left px-5 py-3 font-medium">Name</th>
|
||||
<th className="text-left px-5 py-3 font-medium">Name (ES)</th>
|
||||
<th className="text-left px-5 py-3 font-medium">Unit</th>
|
||||
<th className="text-left px-5 py-3 font-medium">Sort</th>
|
||||
<th className="text-left px-5 py-3 font-medium">Status</th>
|
||||
<th className="text-right px-5 py-3 font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tasks.length === 0 ? (
|
||||
<tr><td colSpan={6} className="px-5 py-12 text-center text-stone-400">No tasks yet</td></tr>
|
||||
) : tasks.map(t => (
|
||||
<tr key={t.id} className="border-t border-stone-100 hover:bg-stone-50 transition-colors">
|
||||
<td className="px-5 py-3.5 text-stone-800 font-medium">{t.name}</td>
|
||||
<td className="px-5 py-3.5 text-stone-500">{t.name_es ?? "—"}</td>
|
||||
<td className="px-5 py-3.5 text-stone-400 text-xs font-mono">{t.unit}</td>
|
||||
<td className="px-5 py-3.5 text-stone-400 text-xs">{t.sort_order}</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold ${t.active ? "bg-emerald-100 text-emerald-700" : "bg-stone-100 text-stone-500"}`}>
|
||||
{t.active ? "Active" : "Inactive"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-3.5 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button onClick={() => openEditTask(t)} className="text-xs text-stone-500 hover:text-stone-800 px-2 py-1 rounded-lg hover:bg-stone-100 transition-all">Edit</button>
|
||||
<button onClick={() => handleDeleteTask(t.id)} className="text-xs text-red-500 hover:text-red-700 px-2 py-1 rounded-lg hover:bg-red-50 transition-all">Delete</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pay Period & Overtime section */}
|
||||
<div className="bg-white border border-stone-200 rounded-xl p-6 space-y-5 shadow-sm">
|
||||
<h3 className="text-sm font-semibold text-stone-800">Pay Period & Overtime</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-2">Work week starts on</label>
|
||||
<select value={payPeriodStartDay} onChange={e => setPayPeriodStartDay(Number(e.target.value))}
|
||||
className="w-full px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 focus:outline-none focus:border-emerald-500 transition-colors">
|
||||
{DAYS.map((d, i) => <option key={i} value={i}>{d}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-2">Pay period length</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<input type="number" min={1} max={31} value={payPeriodLength}
|
||||
onChange={e => setPayPeriodLength(Number(e.target.value))}
|
||||
className="flex-1 px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 focus:outline-none focus:border-emerald-500 transition-colors" />
|
||||
<span className="text-sm text-stone-500">days</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-2">Daily overtime threshold</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<input type="number" min={1} max={24} value={dailyOvertimeThreshold}
|
||||
onChange={e => setDailyOvertimeThreshold(Number(e.target.value))}
|
||||
className="flex-1 px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 focus:outline-none focus:border-emerald-500 transition-colors" />
|
||||
<span className="text-sm text-stone-500">hrs</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-2">Weekly overtime threshold</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<input type="number" min={1} max={80} value={weeklyOvertimeThreshold}
|
||||
onChange={e => setWeeklyOvertimeThreshold(Number(e.target.value))}
|
||||
className="flex-1 px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 focus:outline-none focus:border-emerald-500 transition-colors" />
|
||||
<span className="text-sm text-stone-500">hrs</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Colorado notice */}
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-xl p-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-amber-600 text-lg mt-0.5">⚠️</span>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-amber-800">Colorado Overtime Law</p>
|
||||
<p className="text-xs text-amber-700 mt-1">Colorado requires daily overtime (1.5×) after 12 hours in a workday, or weekly overtime after 40 hours in a workweek.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Alert Settings */}
|
||||
<div className="bg-white border border-stone-200 rounded-xl p-6 space-y-5 shadow-sm">
|
||||
<h3 className="text-sm font-semibold text-stone-800">Alert Settings</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-stone-700 font-medium">Daily overtime alerts</p>
|
||||
<p className="text-xs text-stone-500">Send notification when worker hits daily threshold</p>
|
||||
</div>
|
||||
<button onClick={() => setEnableDailyAlerts(!enableDailyAlerts)}
|
||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${enableDailyAlerts ? "bg-emerald-600" : "bg-stone-300"}`}>
|
||||
<span className={`inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform ${enableDailyAlerts ? "translate-x-4" : "translate-x-1"}`} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-stone-700 font-medium">Weekly overtime alerts</p>
|
||||
<p className="text-xs text-stone-500">Send notification when worker hits weekly threshold</p>
|
||||
</div>
|
||||
<button onClick={() => setEnableWeeklyAlerts(!enableWeeklyAlerts)}
|
||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${enableWeeklyAlerts ? "bg-emerald-600" : "bg-stone-300"}`}>
|
||||
<span className={`inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform ${enableWeeklyAlerts ? "translate-x-4" : "translate-x-1"}`} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-stone-700 font-medium">Show overtime warning on employee screen</p>
|
||||
<p className="text-xs text-stone-500">Display alert when worker approaches thresholds</p>
|
||||
</div>
|
||||
<button onClick={() => setOvertimeNotifications(!overtimeNotifications)}
|
||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${overtimeNotifications ? "bg-emerald-600" : "bg-stone-300"}`}>
|
||||
<span className={`inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform ${overtimeNotifications ? "translate-x-4" : "translate-x-1"}`} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notification Recipients */}
|
||||
<div className="bg-white border border-stone-200 rounded-xl p-6 space-y-4 shadow-sm">
|
||||
<h3 className="text-sm font-semibold text-stone-800">Notification Recipients</h3>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-2">Email addresses</label>
|
||||
<div className="flex gap-2">
|
||||
<input value={newEmail}
|
||||
onChange={e => setNewEmail(e.target.value)}
|
||||
onKeyDown={e => e.key === "Enter" && (addEmail(), e.preventDefault())}
|
||||
className="flex-1 px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 placeholder:text-stone-400 focus:outline-none focus:border-emerald-500 transition-colors"
|
||||
placeholder="manager@farm.com" />
|
||||
<button onClick={addEmail} className="px-4 py-3 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white font-semibold text-sm transition-all">Add</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{notificationEmails.map(e => (
|
||||
<span key={e} className="inline-flex items-center gap-1 bg-stone-100 text-stone-700 text-xs px-2 py-1 rounded-lg">
|
||||
{e}
|
||||
<button onClick={() => removeEmail(e)} className="text-stone-400 hover:text-red-500 ml-1">×</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-2">SMS numbers</label>
|
||||
<div className="flex gap-2">
|
||||
<input value={newSms}
|
||||
onChange={e => setNewSms(e.target.value)}
|
||||
onKeyDown={e => e.key === "Enter" && (addSms(), e.preventDefault())}
|
||||
className="flex-1 px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 placeholder:text-stone-400 focus:outline-none focus:border-emerald-500 transition-colors"
|
||||
placeholder="+1234567890" />
|
||||
<button onClick={addSms} className="px-4 py-3 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white font-semibold text-sm transition-all">Add</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{notificationSmsNumbers.map(n => (
|
||||
<span key={n} className="inline-flex items-center gap-1 bg-stone-100 text-stone-700 text-xs px-2 py-1 rounded-lg">
|
||||
{n}
|
||||
<button onClick={() => removeSms(n)} className="text-stone-400 hover:text-red-500 ml-1">×</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{settingsError && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-xl py-3 px-4 text-red-700 text-sm">{settingsError}</div>
|
||||
)}
|
||||
{settingsSaved && (
|
||||
<div className="bg-emerald-50 border border-emerald-200 rounded-xl py-3 px-4 text-emerald-700 text-sm">Settings saved successfully.</div>
|
||||
)}
|
||||
<button onClick={handleSaveNotifications} disabled={settingsSaving}
|
||||
className="px-6 py-3 rounded-xl bg-stone-900 hover:bg-stone-800 disabled:opacity-40 font-semibold text-sm text-white transition-all">
|
||||
{settingsSaving ? "Saving..." : "Save Settings"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Worker Modal */}
|
||||
{showWorkerModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 backdrop-blur-sm">
|
||||
<div className="bg-white border border-stone-200 rounded-2xl w-full max-w-md shadow-xl">
|
||||
<div className="px-6 py-4 border-b border-stone-100 flex items-center justify-between">
|
||||
<h3 className="text-lg font-bold text-stone-900">{editingWorker ? "Edit Worker" : "Add Worker"}</h3>
|
||||
<button onClick={() => setShowWorkerModal(false)} className="text-stone-400 hover:text-stone-600 text-xl leading-none">×</button>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
{workerError && <div className="bg-red-50 border border-red-200 rounded-xl py-3 px-4 text-red-700 text-sm">{workerError}</div>}
|
||||
{resetPinResult && (
|
||||
<div className="bg-emerald-50 border border-emerald-200 rounded-xl py-3 px-4">
|
||||
<p className="text-emerald-700 text-sm font-semibold mb-1">New PIN:</p>
|
||||
<p className="text-2xl font-mono font-bold text-stone-900">{resetPinResult}</p>
|
||||
<p className="text-stone-500 text-xs mt-1">Show this to the worker — it will not be shown again.</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-1.5">Name *</label>
|
||||
<input value={workerName} onChange={e => setWorkerName(e.target.value)}
|
||||
className="w-full px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 placeholder:text-stone-400 focus:outline-none focus:border-emerald-500 transition-colors"
|
||||
placeholder="Worker name" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-1.5">Role</label>
|
||||
<select value={workerRole} onChange={e => setWorkerRole(e.target.value)}
|
||||
className="w-full px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 focus:outline-none focus:border-emerald-500 transition-colors">
|
||||
<option value="worker">Worker</option>
|
||||
<option value="time_admin">Time Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-1.5">Lang</label>
|
||||
<select value={workerLang} onChange={e => setWorkerLang(e.target.value)}
|
||||
className="w-full px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 focus:outline-none focus:border-emerald-500 transition-colors">
|
||||
<option value="en">English</option>
|
||||
<option value="es">Español</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input type="checkbox" id="workerActive" checked={workerActive} onChange={e => setWorkerActive(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-stone-300 bg-white text-emerald-600" />
|
||||
<label htmlFor="workerActive" className="text-sm text-stone-700">Active</label>
|
||||
</div>
|
||||
{editingWorker && (
|
||||
<div className="pt-2 border-t border-stone-100">
|
||||
<button onClick={() => handleResetPin(editingWorker.id)} className="text-xs text-amber-600 hover:text-amber-800 font-semibold underline underline-offset-2">
|
||||
Reset PIN for this worker
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button onClick={() => setShowWorkerModal(false)}
|
||||
className="flex-1 py-3 rounded-xl font-semibold text-sm text-stone-500 hover:text-stone-700 border border-stone-200 hover:border-stone-300 transition-all">
|
||||
Cancel
|
||||
</button>
|
||||
<button onClick={handleSaveWorker} disabled={submitting}
|
||||
className="flex-1 py-3 rounded-xl font-bold text-sm bg-emerald-600 hover:bg-emerald-500 disabled:opacity-40 text-white transition-all">
|
||||
{submitting ? "..." : editingWorker ? "Save" : "Add Worker"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Task Modal */}
|
||||
{showTaskModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 backdrop-blur-sm">
|
||||
<div className="bg-white border border-stone-200 rounded-2xl w-full max-w-md shadow-xl">
|
||||
<div className="px-6 py-4 border-b border-stone-100 flex items-center justify-between">
|
||||
<h3 className="text-lg font-bold text-stone-900">{editingTask ? "Edit Task" : "Add Task"}</h3>
|
||||
<button onClick={() => setShowTaskModal(false)} className="text-stone-400 hover:text-stone-600 text-xl leading-none">×</button>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
{taskError && <div className="bg-red-50 border border-red-200 rounded-xl py-3 px-4 text-red-700 text-sm">{taskError}</div>}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-1.5">Name (EN) *</label>
|
||||
<input value={taskName} onChange={e => setTaskName(e.target.value)}
|
||||
className="w-full px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 placeholder:text-stone-400 focus:outline-none focus:border-emerald-500 transition-colors"
|
||||
placeholder="e.g. Harvesting" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-1.5">Name (ES)</label>
|
||||
<input value={taskNameEs} onChange={e => setTaskNameEs(e.target.value)}
|
||||
className="w-full px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 placeholder:text-stone-400 focus:outline-none focus:border-emerald-500 transition-colors"
|
||||
placeholder="e.g. Cosecha" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-1.5">Unit</label>
|
||||
<select value={taskUnit} onChange={e => setTaskUnit(e.target.value)}
|
||||
className="w-full px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 focus:outline-none focus:border-emerald-500 transition-colors">
|
||||
<option value="hours">Hours</option>
|
||||
<option value="pieces">Pieces</option>
|
||||
<option value="units">Units</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-1.5">Sort Order</label>
|
||||
<input type="number" value={taskSortOrder} onChange={e => setTaskSortOrder(parseInt(e.target.value) || 0)}
|
||||
className="w-full px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 focus:outline-none focus:border-emerald-500 transition-colors" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input type="checkbox" id="taskActive" checked={taskActive} onChange={e => setTaskActive(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-stone-300 bg-white text-emerald-600" />
|
||||
<label htmlFor="taskActive" className="text-sm text-stone-700">Active</label>
|
||||
</div>
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button onClick={() => setShowTaskModal(false)}
|
||||
className="flex-1 py-3 rounded-xl font-semibold text-sm text-stone-500 hover:text-stone-700 border border-stone-200 hover:border-stone-300 transition-all">
|
||||
Cancel
|
||||
</button>
|
||||
<button onClick={handleSaveTask} disabled={submitting}
|
||||
className="flex-1 py-3 rounded-xl font-bold text-sm bg-emerald-600 hover:bg-emerald-500 disabled:opacity-40 text-white transition-all">
|
||||
{submitting ? "..." : editingTask ? "Save" : "Add Task"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,656 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { AdminUserRow, CreateAdminUserInput, UpdateAdminUserInput } from "@/actions/admin/users";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
|
||||
type UsersPageProps = {
|
||||
initialUsers: AdminUserRow[];
|
||||
brands: { id: string; name: string }[];
|
||||
currentUser: {
|
||||
id: string;
|
||||
role: string;
|
||||
can_manage_users?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
type PasswordModal = {
|
||||
email: string;
|
||||
tempPassword: string | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
const FLAG_LABELS: Record<string, string> = {
|
||||
can_manage_products: "Products",
|
||||
can_manage_stops: "Stops",
|
||||
can_manage_orders: "Orders",
|
||||
can_manage_pickup: "Pickup",
|
||||
can_manage_messages: "Messages",
|
||||
can_manage_refunds: "Refunds",
|
||||
can_manage_users: "Users",
|
||||
can_manage_water_log: "Water Log",
|
||||
can_manage_reports: "Reports",
|
||||
};
|
||||
|
||||
const ALL_FLAGS = Object.keys(FLAG_LABELS);
|
||||
|
||||
function RoleBadge({ role }: { role: string }) {
|
||||
const colors: Record<string, string> = {
|
||||
platform_admin: "bg-purple-100 text-purple-700",
|
||||
brand_admin: "bg-blue-100 text-blue-700",
|
||||
store_employee: "bg-emerald-100 text-emerald-700",
|
||||
staff: "bg-stone-100 text-stone-600",
|
||||
};
|
||||
return (
|
||||
<span className={`rounded-full px-2.5 py-0.5 text-xs font-semibold capitalize ${colors[role] ?? "bg-stone-100 text-stone-600"}`}>
|
||||
{role.replace("_", " ")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
type EditingUser = {
|
||||
id?: string;
|
||||
email?: string;
|
||||
password?: string;
|
||||
display_name?: string;
|
||||
phone_number?: string;
|
||||
role: "platform_admin" | "brand_admin" | "store_employee";
|
||||
brand_id: string | null;
|
||||
flags: Record<string, boolean>;
|
||||
active?: boolean;
|
||||
isNew: boolean;
|
||||
};
|
||||
|
||||
const defaultFlags: Record<string, boolean> = {
|
||||
can_manage_products: false,
|
||||
can_manage_stops: false,
|
||||
can_manage_orders: false,
|
||||
can_manage_pickup: false,
|
||||
can_manage_messages: false,
|
||||
can_manage_refunds: false,
|
||||
can_manage_users: false,
|
||||
can_manage_water_log: false,
|
||||
can_manage_reports: false,
|
||||
};
|
||||
|
||||
function emptyEditing(isNew: boolean): EditingUser {
|
||||
return {
|
||||
isNew,
|
||||
role: "store_employee",
|
||||
brand_id: null,
|
||||
display_name: "",
|
||||
phone_number: "",
|
||||
password: "",
|
||||
flags: { ...defaultFlags },
|
||||
};
|
||||
}
|
||||
|
||||
function editingFromRow(row: AdminUserRow): EditingUser {
|
||||
return {
|
||||
id: row.id,
|
||||
email: row.email,
|
||||
display_name: row.display_name ?? "",
|
||||
phone_number: row.phone_number ?? "",
|
||||
role: row.role as "platform_admin" | "brand_admin" | "store_employee",
|
||||
brand_id: row.brand_id,
|
||||
flags: {
|
||||
can_manage_products: row.can_manage_products,
|
||||
can_manage_stops: row.can_manage_stops,
|
||||
can_manage_orders: row.can_manage_orders,
|
||||
can_manage_pickup: row.can_manage_pickup,
|
||||
can_manage_messages: row.can_manage_messages,
|
||||
can_manage_refunds: row.can_manage_refunds,
|
||||
can_manage_users: row.can_manage_users,
|
||||
can_manage_water_log: row.can_manage_water_log,
|
||||
can_manage_reports: row.can_manage_reports,
|
||||
},
|
||||
active: row.active,
|
||||
isNew: false,
|
||||
};
|
||||
}
|
||||
|
||||
export default function UsersPage({ initialUsers, brands, currentUser }: UsersPageProps) {
|
||||
const [users, setUsers] = useState(initialUsers);
|
||||
const [panelOpen, setPanelOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<EditingUser>(emptyEditing(true));
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||
const [actionMenuId, setActionMenuId] = useState<string | null>(null);
|
||||
const [passwordModal, setPasswordModal] = useState<PasswordModal | null>(null);
|
||||
const [resetLinkLoading, setResetLinkLoading] = useState<string | null>(null);
|
||||
|
||||
const canManageUsers =
|
||||
currentUser.role === "platform_admin" ||
|
||||
(currentUser.role === "brand_admin" && currentUser.can_manage_users);
|
||||
|
||||
function openCreate() {
|
||||
setEditing(emptyEditing(true));
|
||||
setError(null);
|
||||
setPanelOpen(true);
|
||||
}
|
||||
|
||||
function openEdit(row: AdminUserRow) {
|
||||
setEditing(editingFromRow(row));
|
||||
setError(null);
|
||||
setPanelOpen(true);
|
||||
}
|
||||
|
||||
function closePanel() {
|
||||
setPanelOpen(false);
|
||||
setEditing(emptyEditing(true));
|
||||
setError(null);
|
||||
}
|
||||
|
||||
function setRole(role: EditingUser["role"]) {
|
||||
setEditing((prev) => ({ ...prev, role }));
|
||||
}
|
||||
|
||||
function setBrand(brand_id: string | null) {
|
||||
setEditing((prev) => ({ ...prev, brand_id }));
|
||||
}
|
||||
|
||||
function toggleFlag(flag: string) {
|
||||
setEditing((prev) => ({
|
||||
...prev,
|
||||
flags: { ...prev.flags, [flag]: !prev.flags[flag] },
|
||||
}));
|
||||
}
|
||||
|
||||
function setActive(active: boolean) {
|
||||
setEditing((prev) => ({ ...prev, active }));
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
if (editing.isNew) {
|
||||
const res = await import("@/actions/admin/users").then((m) =>
|
||||
m.createAdminUser({
|
||||
email: editing.email!,
|
||||
password: editing.password!,
|
||||
display_name: editing.display_name || undefined,
|
||||
phone_number: editing.phone_number || undefined,
|
||||
role: editing.role,
|
||||
brand_id: editing.brand_id,
|
||||
flags: editing.flags,
|
||||
mustChangePassword: true,
|
||||
})
|
||||
);
|
||||
if (res.error) {
|
||||
if (res.error === "Not authenticated" || res.error.includes("session") || res.error.includes("authenticated")) {
|
||||
setError("Your session may have expired. Please log in again.");
|
||||
} else if (res.error.includes("permission") || res.error.includes("Insufficient permissions")) {
|
||||
setError("You do not have permission to edit this user.");
|
||||
} else {
|
||||
setError(res.error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (res.user) setUsers((prev) => [res.user!, ...prev]);
|
||||
} else {
|
||||
const res = await import("@/actions/admin/users").then((m) =>
|
||||
m.updateAdminUser({
|
||||
id: editing.id!,
|
||||
role: editing.role,
|
||||
brand_id: editing.brand_id,
|
||||
flags: editing.flags,
|
||||
active: editing.active,
|
||||
display_name: editing.display_name === "" ? null : editing.display_name || undefined,
|
||||
phone_number: editing.phone_number === "" ? null : editing.phone_number || undefined,
|
||||
})
|
||||
);
|
||||
if (res.error) {
|
||||
if (res.error === "Not authenticated" || res.error.includes("session") || res.error.includes("authenticated")) {
|
||||
setError("Your session may have expired. Please log in again.");
|
||||
} else if (res.error.includes("permission") || res.error.includes("Insufficient permissions")) {
|
||||
setError("You do not have permission to edit this user.");
|
||||
} else {
|
||||
setError(res.error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (res.user) setUsers((prev) => prev.map((u) => (u.id === res.user!.id ? res.user! : u)));
|
||||
}
|
||||
closePanel();
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
const res = await import("@/actions/admin/users").then((m) => m.deleteAdminUser(id));
|
||||
if (res.error) { setError(res.error); return; }
|
||||
setUsers((prev) => prev.filter((u) => u.id !== id));
|
||||
setDeleteConfirmId(null);
|
||||
}
|
||||
|
||||
async function handleResetPassword(email: string) {
|
||||
setPasswordModal({ email, tempPassword: null, loading: true, error: null });
|
||||
try {
|
||||
const { resetAdminPassword } = await import("@/actions/admin/reset-admin");
|
||||
const result = await resetAdminPassword(email, "Tuxedo2026!");
|
||||
if (result.success) {
|
||||
setPasswordModal({ email, tempPassword: result.tempPassword, loading: false, error: null });
|
||||
} else {
|
||||
setPasswordModal({ email, tempPassword: null, loading: false, error: result.error ?? "Failed" });
|
||||
}
|
||||
} catch (e: any) {
|
||||
setPasswordModal({ email, tempPassword: null, loading: false, error: e?.message ?? "Error" });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleForcePasswordChange(id: string) {
|
||||
try {
|
||||
const { setMustChangePassword } = await import("@/actions/admin/users");
|
||||
const res = await setMustChangePassword(id);
|
||||
if (res.error) throw new Error(res.error);
|
||||
setUsers((prev) => prev.map((u) => u.id === id ? { ...u, must_change_password: true } : u));
|
||||
} catch (e: any) {
|
||||
setError(e?.message ?? "Failed to require password change");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSendResetLink(email: string) {
|
||||
setResetLinkLoading(email);
|
||||
try {
|
||||
const { sendPasswordResetEmail } = await import("@/actions/admin/users");
|
||||
const result = await sendPasswordResetEmail(email);
|
||||
setResetLinkLoading(null);
|
||||
if (result.error) throw new Error(result.error);
|
||||
setError(null);
|
||||
} catch (e: any) {
|
||||
setResetLinkLoading(null);
|
||||
setError(e?.message ?? "Failed to send reset email");
|
||||
}
|
||||
}
|
||||
|
||||
function copyTempPassword() {
|
||||
if (passwordModal?.tempPassword) {
|
||||
navigator.clipboard.writeText(passwordModal.tempPassword).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
const showBrandSelect = editing.role === "brand_admin" || editing.role === "store_employee";
|
||||
const availableRoles: EditingUser["role"][] =
|
||||
currentUser.role === "platform_admin"
|
||||
? ["platform_admin", "brand_admin", "store_employee"]
|
||||
: ["brand_admin", "store_employee"];
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* User list */}
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-stone-900">{users.length} user{users.length !== 1 ? "s" : ""}</h2>
|
||||
</div>
|
||||
{canManageUsers && (
|
||||
<button
|
||||
onClick={openCreate}
|
||||
className="rounded-lg bg-stone-900 px-4 py-2 text-sm font-medium text-white hover:bg-stone-800"
|
||||
>
|
||||
+ Create User
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error banner */}
|
||||
{error && (
|
||||
<div className="mb-4 flex items-start justify-between rounded-lg bg-red-50 p-4 text-sm text-red-700 gap-3 border border-red-200">
|
||||
<span>{error}</span>
|
||||
<button onClick={() => setError(null)} className="text-red-400 hover:text-red-600 shrink-0">✕</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="overflow-visible rounded-xl bg-white shadow-sm ring-1 ring-stone-200">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="bg-stone-50 text-stone-500">
|
||||
<tr>
|
||||
<th className="px-5 py-4 font-semibold">Name</th>
|
||||
<th className="px-5 py-4 font-semibold">Phone</th>
|
||||
<th className="px-5 py-4 font-semibold">Role</th>
|
||||
<th className="px-5 py-4 font-semibold">Brand</th>
|
||||
<th className="px-5 py-4 font-semibold">Status</th>
|
||||
<th className="px-5 py-4 font-semibold">Created</th>
|
||||
{canManageUsers && <th className="px-5 py-4 font-semibold" />}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-stone-100">
|
||||
{users.map((user) => (
|
||||
<tr key={user.id} className="hover:bg-stone-50">
|
||||
<td className="px-5 py-4">
|
||||
<div className="font-medium text-stone-900">{user.display_name || user.email}</div>
|
||||
{user.display_name && user.display_name !== "No Name" && (
|
||||
<div className="text-xs text-stone-500">{user.email}</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-5 py-4 text-stone-500 text-sm">{user.phone_number ?? "—"}</td>
|
||||
<td className="px-5 py-4">
|
||||
<RoleBadge role={user.role} />
|
||||
</td>
|
||||
<td className="px-5 py-4 text-stone-500">{user.brand_name ?? "—"}</td>
|
||||
<td className="px-5 py-4">
|
||||
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${user.active ? "bg-emerald-100 text-emerald-700" : "bg-stone-100 text-stone-500"}`}>
|
||||
{user.active ? "Active" : "Inactive"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-4 text-stone-400 text-xs">
|
||||
{formatDate(new Date(user.created_at))}
|
||||
</td>
|
||||
{canManageUsers && (
|
||||
<td className="px-5 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => openEdit(user)}
|
||||
className="text-xs font-medium text-stone-500 hover:text-stone-900"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<div className="relative overflow-visible">
|
||||
<button
|
||||
onClick={() => setActionMenuId(actionMenuId === user.id ? null : user.id)}
|
||||
className="text-base font-medium text-stone-400 hover:text-stone-600 px-2 py-1"
|
||||
title="More actions"
|
||||
>
|
||||
⁝
|
||||
</button>
|
||||
{actionMenuId === user.id && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setActionMenuId(null)} />
|
||||
<div className="absolute right-0 z-50 mt-1 w-48 rounded-xl bg-white shadow-xl ring-1 ring-stone-200 border border-stone-100 py-1 text-xs overflow-visible">
|
||||
<div className="px-3 py-2 text-[10px] font-semibold uppercase tracking-wider text-stone-400 border-b border-stone-100">
|
||||
Actions
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { setActionMenuId(null); handleResetPassword(user.email); }}
|
||||
className="w-full text-left px-3 py-2.5 text-stone-700 hover:bg-stone-50 flex items-center gap-2"
|
||||
>
|
||||
🔑 Reset Password
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setActionMenuId(null); handleForcePasswordChange(user.id); }}
|
||||
className="w-full text-left px-3 py-2.5 text-stone-700 hover:bg-stone-50 flex items-center gap-2"
|
||||
>
|
||||
↪ Require Password Change
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setActionMenuId(null); handleSendResetLink(user.email); }}
|
||||
className="w-full text-left px-3 py-2.5 text-stone-700 hover:bg-stone-50 flex items-center gap-2"
|
||||
>
|
||||
✉ Send Reset Email
|
||||
</button>
|
||||
{user.id !== currentUser.id && (
|
||||
<button
|
||||
onClick={() => { setActionMenuId(null); setDeleteConfirmId(user.id); }}
|
||||
className="w-full text-left px-3 py-2.5 text-red-600 hover:bg-red-50 flex items-center gap-2 border-t border-stone-100 mt-1 pt-1"
|
||||
>
|
||||
🗑 Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
{users.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-5 py-12 text-center text-stone-400">
|
||||
No users found.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Delete confirm modal */}
|
||||
{deleteConfirmId && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
|
||||
<div className="w-96 rounded-2xl bg-white p-6 shadow-xl ring-1 ring-stone-200">
|
||||
<h3 className="text-lg font-bold text-stone-900">Delete user?</h3>
|
||||
<p className="mt-2 text-sm text-stone-600">
|
||||
This will remove their admin access. Their auth account will remain active.
|
||||
</p>
|
||||
<div className="mt-6 flex justify-end gap-3">
|
||||
<button
|
||||
onClick={() => setDeleteConfirmId(null)}
|
||||
className="rounded-lg border border-stone-200 px-4 py-2 text-sm font-medium text-stone-600 hover:bg-stone-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(deleteConfirmId)}
|
||||
className="rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Slide-in panel */}
|
||||
{panelOpen && (
|
||||
<div className="fixed inset-0 z-50 flex justify-end bg-black/20" onClick={closePanel}>
|
||||
<div
|
||||
className="relative flex h-full w-full max-w-md flex-col overflow-y-auto bg-white shadow-xl ring-1 ring-stone-200"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-stone-100 px-6 py-4">
|
||||
<h2 className="text-lg font-bold text-stone-900">
|
||||
{editing.isNew ? "Create User" : "Edit User"}
|
||||
</h2>
|
||||
<button onClick={closePanel} className="text-stone-400 hover:text-stone-600">
|
||||
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Saving overlay */}
|
||||
{saving && (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center bg-white/70">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-stone-200 border-t-stone-900" />
|
||||
<p className="text-sm text-stone-500">Saving…</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 py-6">
|
||||
<div className="space-y-6">
|
||||
{/* Email (create only) */}
|
||||
{editing.isNew && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-stone-700">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={editing.email ?? ""}
|
||||
onChange={(e) => setEditing((p) => ({ ...p, email: e.target.value }))}
|
||||
className="mt-1 w-full rounded-lg border border-stone-200 bg-white px-3 py-2.5 text-sm text-stone-900 placeholder:text-stone-400 focus:border-emerald-500 focus:outline-none focus:ring-1 focus:ring-emerald-500"
|
||||
placeholder="user@example.com"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Password (create only) */}
|
||||
{editing.isNew && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-stone-700">Password</label>
|
||||
<p className="mt-1 text-xs text-stone-500 mb-1.5">Minimum 6 characters.</p>
|
||||
<input
|
||||
type="password"
|
||||
value={editing.password ?? ""}
|
||||
onChange={(e) => setEditing((p) => ({ ...p, password: e.target.value }))}
|
||||
className="w-full rounded-lg border border-stone-200 bg-white px-3 py-2.5 text-sm text-stone-900 placeholder:text-stone-400 focus:border-emerald-500 focus:outline-none focus:ring-1 focus:ring-emerald-500"
|
||||
placeholder="Choose a password"
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Display Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-stone-700">Display Name</label>
|
||||
<p className="mt-1 text-xs text-stone-500 mb-1.5">Shown in the admin user list.</p>
|
||||
<input
|
||||
type="text"
|
||||
value={editing.display_name ?? ""}
|
||||
onChange={(e) => setEditing((p) => ({ ...p, display_name: e.target.value }))}
|
||||
className="w-full rounded-lg border border-stone-200 bg-white px-3 py-2.5 text-sm text-stone-900 placeholder:text-stone-400 focus:border-emerald-500 focus:outline-none focus:ring-1 focus:ring-emerald-500"
|
||||
placeholder="Kyle Martinez"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Phone Number */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-stone-700">Phone Number</label>
|
||||
<p className="mt-1 text-xs text-stone-500 mb-1.5">Optional.</p>
|
||||
<input
|
||||
type="tel"
|
||||
value={editing.phone_number ?? ""}
|
||||
onChange={(e) => setEditing((p) => ({ ...p, phone_number: e.target.value }))}
|
||||
className="w-full rounded-lg border border-stone-200 bg-white px-3 py-2.5 text-sm text-stone-900 placeholder:text-stone-400 focus:border-emerald-500 focus:outline-none focus:ring-1 focus:ring-emerald-500"
|
||||
placeholder="+1 (555) 000-0000"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Role */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-stone-700">Role</label>
|
||||
<div className="mt-2 space-y-2">
|
||||
{availableRoles.map((r) => (
|
||||
<label key={r} className="flex items-center gap-3 rounded-lg border border-stone-200 px-3 py-2.5 cursor-pointer hover:bg-stone-50">
|
||||
<input
|
||||
type="radio"
|
||||
name="role"
|
||||
value={r}
|
||||
checked={editing.role === r}
|
||||
onChange={() => setRole(r)}
|
||||
className="accent-stone-900"
|
||||
/>
|
||||
<div>
|
||||
<span className="font-medium text-stone-900 capitalize">{r.replace("_", " ")}</span>
|
||||
<p className="text-xs text-stone-500">
|
||||
{r === "platform_admin" ? "Full platform access" :
|
||||
r === "brand_admin" ? "Brand-level admin" :
|
||||
"Pickup and order operations only"}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Brand */}
|
||||
{showBrandSelect && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-stone-700">Brand</label>
|
||||
<select
|
||||
value={editing.brand_id ?? ""}
|
||||
onChange={(e) => setBrand(e.target.value || null)}
|
||||
className="mt-1 w-full rounded-lg border border-stone-200 px-3 py-2.5 text-sm text-stone-900 focus:border-emerald-500 focus:outline-none focus:ring-1 focus:ring-emerald-500"
|
||||
>
|
||||
<option value="">No brand (full platform scope)</option>
|
||||
{brands.map((b) => (
|
||||
<option key={b.id} value={b.id}>{b.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Active toggle (edit only) */}
|
||||
{!editing.isNew && (
|
||||
<div className="flex items-center justify-between rounded-lg border border-stone-200 px-4 py-3">
|
||||
<div>
|
||||
<p className="font-medium text-stone-900">Active</p>
|
||||
<p className="text-xs text-stone-500">Deactivated users cannot log in</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setActive(!editing.active)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${editing.active ? "bg-emerald-600" : "bg-stone-300"}`}
|
||||
>
|
||||
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${editing.active ? "translate-x-6" : "translate-x-1"}`} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Permissions */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-stone-700 mb-3">Permissions</label>
|
||||
<div className="space-y-2">
|
||||
{ALL_FLAGS.map((flag) => (
|
||||
<label key={flag} className="flex items-center justify-between rounded-lg border border-stone-200 px-4 py-2.5 hover:bg-stone-50 cursor-pointer">
|
||||
<span className="text-sm text-stone-700">{FLAG_LABELS[flag]}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleFlag(flag)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${editing.flags[flag] ? "bg-emerald-600" : "bg-stone-300"}`}
|
||||
>
|
||||
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${editing.flags[flag] ? "translate-x-6" : "translate-x-1"}`} />
|
||||
</button>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t px-6 py-4">
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
onClick={closePanel}
|
||||
className="rounded-lg border border-stone-200 px-4 py-2 text-sm font-medium text-stone-600 hover:bg-stone-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving || (editing.isNew && (!editing.email?.includes("@") || !editing.password || editing.password.length < 6))}
|
||||
className="rounded-lg bg-stone-900 px-4 py-2 text-sm font-medium text-white hover:bg-stone-800 disabled:opacity-50"
|
||||
>
|
||||
{saving ? "Saving…" : editing.isNew ? "Create User" : "Save Changes"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Password reset modal */}
|
||||
{passwordModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
|
||||
<div className="w-96 rounded-2xl bg-white p-6 shadow-xl ring-1 ring-stone-200">
|
||||
<h3 className="text-lg font-bold text-stone-900">Reset Password</h3>
|
||||
{passwordModal.loading ? (
|
||||
<div className="mt-4 flex items-center gap-3 text-sm text-stone-500">
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-stone-200 border-t-stone-900" />
|
||||
Resetting password for {passwordModal.email}...
|
||||
</div>
|
||||
) : passwordModal.error ? (
|
||||
<div>
|
||||
<p className="mt-3 text-sm text-red-600">{passwordModal.error}</p>
|
||||
<button onClick={() => setPasswordModal(null)} className="mt-4 w-full rounded-lg border border-stone-200 px-4 py-2 text-sm font-medium text-stone-600 hover:bg-stone-50">Close</button>
|
||||
</div>
|
||||
) : passwordModal.tempPassword ? (
|
||||
<div>
|
||||
<p className="mt-3 text-sm text-stone-600">Temporary password for <span className="font-medium text-stone-900">{passwordModal.email}</span>:</p>
|
||||
<div className="mt-3 flex items-center gap-2 rounded-xl bg-stone-50 p-4 ring-1 ring-stone-200">
|
||||
<span className="font-mono text-lg font-bold text-stone-900 select-all">{passwordModal.tempPassword}</span>
|
||||
<button onClick={copyTempPassword} className="shrink-0 rounded-lg bg-stone-200 px-3 py-1 text-xs font-medium text-stone-700 hover:bg-stone-300">Copy</button>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-stone-500">Share this password securely with the user. They will be required to change it on next login.</p>
|
||||
<button onClick={() => setPasswordModal(null)} className="mt-4 w-full rounded-lg bg-stone-900 px-4 py-2 text-sm font-medium text-white hover:bg-stone-800">Done</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,758 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback } from "react";
|
||||
import {
|
||||
createWaterHeadgate,
|
||||
createWaterUser,
|
||||
getWaterEntries,
|
||||
} from "@/actions/water-log/admin";
|
||||
import { downloadWaterLogCSV, filterWaterLogEntries, shapeWaterLogEntry, formatDailyWaterReport, isInIrrigationSeason, type WaterLogFilter, type WaterLogReportRow, type IrrigationSeasonSettings } from "@/lib/water-log-reporting";
|
||||
|
||||
type WaterUser = {
|
||||
id: string;
|
||||
name: string;
|
||||
role: "irrigator" | "water_admin";
|
||||
active: boolean;
|
||||
language_preference: string;
|
||||
last_used_at: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
type Headgate = {
|
||||
id: string;
|
||||
name: string;
|
||||
active: boolean;
|
||||
unit: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
type WaterEntry = {
|
||||
id: string;
|
||||
headgate_id: string;
|
||||
user_id: string;
|
||||
headgate_name: string;
|
||||
user_name: string;
|
||||
measurement: number;
|
||||
unit: string;
|
||||
notes: string | null;
|
||||
submitted_via: string;
|
||||
logged_at: string;
|
||||
};
|
||||
|
||||
type WaterLogAdminPanelProps = {
|
||||
initialUsers: WaterUser[];
|
||||
initialHeadgates: Headgate[];
|
||||
initialEntries: WaterEntry[];
|
||||
brandId: string;
|
||||
canManage: boolean;
|
||||
};
|
||||
|
||||
function formatDateTime(iso: string | null): string {
|
||||
if (!iso) return "—";
|
||||
return new Date(iso).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
export default function WaterLogAdminPanel({
|
||||
initialUsers,
|
||||
initialHeadgates,
|
||||
initialEntries,
|
||||
brandId,
|
||||
canManage,
|
||||
}: WaterLogAdminPanelProps) {
|
||||
const router = useRouter();
|
||||
const [users] = useState<WaterUser[]>(initialUsers);
|
||||
const [headgates, setHeadgates] = useState<Headgate[]>(initialHeadgates);
|
||||
const [entries] = useState<WaterEntry[]>(initialEntries);
|
||||
|
||||
// ── Headgate add state ────────────────────────────
|
||||
const [showAddHg, setShowAddHg] = useState(false);
|
||||
const [newHgName, setNewHgName] = useState("");
|
||||
const [newHgUnit, setNewHgUnit] = useState("CFS");
|
||||
const [savingHg, setSavingHg] = useState(false);
|
||||
|
||||
// ── User add state ─────────────────────────────
|
||||
const [showAddUser, setShowAddUser] = useState(false);
|
||||
const [newUserName, setNewUserName] = useState("");
|
||||
const [newUserRole, setNewUserRole] = useState<"irrigator" | "water_admin">("irrigator");
|
||||
const [newUserLang, setNewUserLang] = useState("en");
|
||||
const [savingUser, setSavingUser] = useState(false);
|
||||
const [newPin, setNewPin] = useState<{ name: string; pin: string } | null>(null);
|
||||
|
||||
// ── CSV filter state ─────────────────────────────
|
||||
const [filterDateFrom, setFilterDateFrom] = useState("");
|
||||
const [filterDateTo, setFilterDateTo] = useState("");
|
||||
const [filterHeadgate, setFilterHeadgate] = useState("");
|
||||
const [filterUser, setFilterUser] = useState("");
|
||||
const [filterVia, setFilterVia] = useState("");
|
||||
const [csvLoading, setCsvLoading] = useState(false);
|
||||
|
||||
// ── Report / messaging settings (localStorage-persisted) ──
|
||||
const [showReportSettings, setShowReportSettings] = useState(false);
|
||||
const [messagingEnabled, setMessagingEnabled] = useState(() => {
|
||||
if (typeof window === "undefined") return false;
|
||||
return localStorage.getItem("wl_messaging_enabled") === "true";
|
||||
});
|
||||
const [seasonStart, setSeasonStart] = useState<IrrigationSeasonSettings>(() => {
|
||||
if (typeof window === "undefined") return { seasonStartMonth: 3, seasonStartDay: 15, seasonEndMonth: 10, seasonEndDay: 15 };
|
||||
const saved = localStorage.getItem("wl_season_settings");
|
||||
return saved ? JSON.parse(saved) : { seasonStartMonth: 3, seasonStartDay: 15, seasonEndMonth: 10, seasonEndDay: 15 };
|
||||
});
|
||||
const [sendEvenIfEmpty, setSendEvenIfEmpty] = useState(() => {
|
||||
if (typeof window === "undefined") return false;
|
||||
return localStorage.getItem("wl_send_even_if_empty") === "true";
|
||||
});
|
||||
const [recipientName, setRecipientName] = useState(() =>
|
||||
typeof window !== "undefined" ? localStorage.getItem("wl_recipient_name") ?? "" : ""
|
||||
);
|
||||
const [recipientPhone, setRecipientPhone] = useState(() =>
|
||||
typeof window !== "undefined" ? localStorage.getItem("wl_recipient_phone") ?? "" : ""
|
||||
);
|
||||
|
||||
// Persist to localStorage on change
|
||||
function persistMessaging(val: boolean) {
|
||||
setMessagingEnabled(val);
|
||||
localStorage.setItem("wl_messaging_enabled", String(val));
|
||||
}
|
||||
function persistSendEvenIfEmpty(val: boolean) {
|
||||
setSendEvenIfEmpty(val);
|
||||
localStorage.setItem("wl_send_even_if_empty", String(val));
|
||||
}
|
||||
function persistSeason(s: IrrigationSeasonSettings) {
|
||||
setSeasonStart(s);
|
||||
localStorage.setItem("wl_season_settings", JSON.stringify(s));
|
||||
}
|
||||
function persistRecipientName(v: string) {
|
||||
setRecipientName(v);
|
||||
localStorage.setItem("wl_recipient_name", v);
|
||||
}
|
||||
function persistRecipientPhone(v: string) {
|
||||
setRecipientPhone(v);
|
||||
localStorage.setItem("wl_recipient_phone", v);
|
||||
}
|
||||
|
||||
// ── Preview report ─────────────────────────────
|
||||
async function handlePreviewReport() {
|
||||
const allEntries = await getWaterEntries(brandId, 500);
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
const todayRows: WaterLogReportRow[] = allEntries
|
||||
.filter((e) => e.logged_at.startsWith(today))
|
||||
.map(shapeWaterLogEntry);
|
||||
|
||||
const inSeason = isInIrrigationSeason(new Date(), seasonStart);
|
||||
const report = formatDailyWaterReport(todayRows, {
|
||||
recipientName: recipientName || undefined,
|
||||
sendEvenIfEmpty,
|
||||
previewMode: true,
|
||||
});
|
||||
|
||||
if (!report) {
|
||||
alert(`[Preview] No entries today — report would be skipped (outside season: ${inSeason ? "in" : "out"}).`);
|
||||
} else {
|
||||
alert(`[Daily Report Preview — ${inSeason ? "in season" : "out of season"}]\n\n${report}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Headgate handlers ────────────────────────────
|
||||
async function handleAddHg(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!newHgName.trim()) return;
|
||||
setSavingHg(true);
|
||||
const result = await createWaterHeadgate(brandId, newHgName.trim(), newHgUnit);
|
||||
if (result.success && result.headgate) {
|
||||
setHeadgates((prev) => [...prev, result.headgate!]);
|
||||
setNewHgName("");
|
||||
setNewHgUnit("CFS");
|
||||
setShowAddHg(false);
|
||||
}
|
||||
setSavingHg(false);
|
||||
}
|
||||
|
||||
// ── User handlers ─────────────────────────────
|
||||
async function handleAddUser(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!newUserName.trim()) return;
|
||||
setSavingUser(true);
|
||||
const result = await createWaterUser(brandId, newUserName.trim(), newUserRole, newUserLang);
|
||||
if (result.success && result.user) {
|
||||
setNewPin({ name: result.user!.name, pin: result.pin! });
|
||||
setNewUserName("");
|
||||
setNewUserRole("irrigator");
|
||||
setNewUserLang("en");
|
||||
setShowAddUser(false);
|
||||
}
|
||||
setSavingUser(false);
|
||||
}
|
||||
|
||||
// ── CSV export ──────────────────────────────────
|
||||
async function handleExportCSV() {
|
||||
setCsvLoading(true);
|
||||
try {
|
||||
const allEntries = await getWaterEntries(brandId, 500);
|
||||
const shaped: WaterLogReportRow[] = allEntries.map(shapeWaterLogEntry);
|
||||
const filters: WaterLogFilter = {
|
||||
dateFrom: filterDateFrom || undefined,
|
||||
dateTo: filterDateTo || undefined,
|
||||
headgateId: filterHeadgate || undefined,
|
||||
userId: filterUser || undefined,
|
||||
submittedVia: filterVia || undefined,
|
||||
};
|
||||
const filtered = filterWaterLogEntries(shaped, filters);
|
||||
const date = new Date().toISOString().split("T")[0];
|
||||
downloadWaterLogCSV(`water-log-${date}.csv`, filtered);
|
||||
} finally {
|
||||
setCsvLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Derived filtered entries for display
|
||||
const filteredEntries = entries.filter((e) => {
|
||||
if (filterDateFrom && e.logged_at < filterDateFrom) return false;
|
||||
if (filterDateTo && e.logged_at > filterDateTo + "T23:59:59.999Z") return false;
|
||||
if (filterHeadgate && e.headgate_id !== filterHeadgate) return false;
|
||||
if (filterUser && e.user_id !== filterUser) return false;
|
||||
if (filterVia && e.submitted_via !== filterVia) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-950">
|
||||
{/* Header */}
|
||||
<div className="bg-zinc-900 border-b border-zinc-800 px-6 py-4">
|
||||
<div className="mx-auto max-w-4xl flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-zinc-100">Water Log</h1>
|
||||
<p className="mt-0.5 text-sm text-zinc-500">
|
||||
PIN-based access · Tuxedo only · separate from site admin
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => router.push("/admin/water-log/headgates")}
|
||||
className="rounded-lg border border-zinc-600 bg-zinc-900 px-3 py-1.5 text-xs font-medium text-zinc-300 hover:bg-zinc-800"
|
||||
>
|
||||
📱 QR Codes
|
||||
</button>
|
||||
<button
|
||||
onClick={() => router.push("/admin/water-log/settings")}
|
||||
className="rounded-lg border border-zinc-600 bg-zinc-900 px-3 py-1.5 text-xs font-medium text-zinc-300 hover:bg-zinc-800"
|
||||
>
|
||||
⚙️ Settings
|
||||
</button>
|
||||
<a
|
||||
href="/water/admin"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-lg border border-zinc-600 bg-zinc-900 px-3 py-1.5 text-xs font-medium text-zinc-300 hover:bg-zinc-800"
|
||||
>
|
||||
↗️ Field Admin
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto max-w-4xl px-6 py-6 space-y-8">
|
||||
|
||||
{/* ── Today's Summary + Report Settings ── */}
|
||||
<section>
|
||||
{/* Summary card */}
|
||||
{(() => {
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
const todayEntries = entries.filter((e) => e.logged_at.startsWith(today));
|
||||
const inSeason = isInIrrigationSeason(new Date(), seasonStart);
|
||||
const totalMeasurement = todayEntries.reduce((s, e) => s + e.measurement, 0);
|
||||
const lastEntry = todayEntries.length > 0
|
||||
? todayEntries.reduce((a, b) => (a.logged_at > b.logged_at ? a : b))
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="rounded-xl bg-zinc-900 shadow-black/20 ring-1 ring-zinc-700 p-5 mb-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-zinc-100">Today's Summary</h2>
|
||||
<p className="text-xs text-zinc-500 mt-0.5">
|
||||
{inSeason
|
||||
? <span className="text-green-600 font-medium">In irrigation season</span>
|
||||
: <span className="text-orange-500 font-medium">Outside irrigation season</span>
|
||||
} · {todayEntries.length} {todayEntries.length === 1 ? "entry" : "entries"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handlePreviewReport}
|
||||
className="rounded-lg border border-zinc-600 bg-zinc-900 px-3 py-1.5 text-xs font-medium text-zinc-300 hover:bg-zinc-800"
|
||||
>
|
||||
Preview Report
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowReportSettings(!showReportSettings)}
|
||||
className={`rounded-lg px-3 py-1.5 text-xs font-medium ${showReportSettings ? "bg-slate-800 text-white" : "border border-zinc-600 bg-zinc-900 text-zinc-300 hover:bg-zinc-800"}`}
|
||||
>
|
||||
Report Settings
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{todayEntries.length > 0 ? (
|
||||
<div className="mt-3 grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<p className="text-xs text-zinc-500">Total measurement</p>
|
||||
<p className="mt-0.5 text-xl font-bold text-zinc-100">{totalMeasurement.toFixed(2)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-zinc-500">Last entry</p>
|
||||
<p className="mt-0.5 text-sm font-semibold text-zinc-300">{lastEntry?.headgate_name}</p>
|
||||
<p className="text-xs text-slate-400">{lastEntry ? formatDateTime(lastEntry.logged_at) : "—"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-zinc-500">Messaging</p>
|
||||
<p className={`mt-0.5 text-sm font-semibold ${messagingEnabled ? "text-green-600" : "text-slate-400"}`}>
|
||||
{messagingEnabled ? "Enabled" : "Disabled"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-2 text-sm text-slate-400">No entries recorded today.</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Report settings panel */}
|
||||
{showReportSettings && (
|
||||
<div className="rounded-xl bg-slate-50 border border-zinc-800 p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<p className="text-sm font-semibold text-zinc-300">Report / Messaging Settings</p>
|
||||
<button onClick={() => setShowReportSettings(false)} className="text-xs text-slate-400 hover:text-zinc-400">Close</button>
|
||||
</div>
|
||||
|
||||
<div className="p-3 mb-3 rounded-lg bg-blue-900/30 border border-blue-100">
|
||||
<p className="text-xs text-blue-700">
|
||||
<strong>Preview only.</strong> These settings are local to this browser. Live scheduled WhatsApp/SMS delivery requires a future database settings table and messaging integration.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-zinc-400 mb-1">Messaging enabled</label>
|
||||
<button
|
||||
onClick={() => persistMessaging(!messagingEnabled)}
|
||||
className={`w-full rounded-lg border px-3 py-2 text-sm font-medium ${messagingEnabled ? "bg-green-900/40 border-green-300 text-green-800" : "bg-zinc-900 border-zinc-600 text-zinc-500"}`}
|
||||
>
|
||||
{messagingEnabled ? "ON" : "OFF"}
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-zinc-400 mb-1">Send even if empty</label>
|
||||
<button
|
||||
onClick={() => persistSendEvenIfEmpty(!sendEvenIfEmpty)}
|
||||
className={`w-full rounded-lg border px-3 py-2 text-sm font-medium ${sendEvenIfEmpty ? "bg-green-900/40 border-green-300 text-green-800" : "bg-zinc-900 border-zinc-600 text-zinc-500"}`}
|
||||
>
|
||||
{sendEvenIfEmpty ? "ON" : "OFF"}
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-zinc-400 mb-1">Season start (month/day)</label>
|
||||
<div className="flex gap-1">
|
||||
<select
|
||||
value={seasonStart.seasonStartMonth}
|
||||
onChange={(e) => persistSeason({ ...seasonStart, seasonStartMonth: parseInt(e.target.value) })}
|
||||
className="flex-1 rounded-lg border border-zinc-600 px-2 py-1.5 text-sm"
|
||||
>
|
||||
{Array.from({ length: 12 }, (_, i) => (
|
||||
<option key={i + 1} value={i + 1}>{new Date(0, i).toLocaleString("en", { month: "short" })}</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="31"
|
||||
value={seasonStart.seasonStartDay}
|
||||
onChange={(e) => persistSeason({ ...seasonStart, seasonStartDay: parseInt(e.target.value) || 1 })}
|
||||
className="w-16 rounded-lg border border-zinc-600 px-2 py-1.5 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-zinc-400 mb-1">Season end (month/day)</label>
|
||||
<div className="flex gap-1">
|
||||
<select
|
||||
value={seasonStart.seasonEndMonth}
|
||||
onChange={(e) => persistSeason({ ...seasonStart, seasonEndMonth: parseInt(e.target.value) })}
|
||||
className="flex-1 rounded-lg border border-zinc-600 px-2 py-1.5 text-sm"
|
||||
>
|
||||
{Array.from({ length: 12 }, (_, i) => (
|
||||
<option key={i + 1} value={i + 1}>{new Date(0, i).toLocaleString("en", { month: "short" })}</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="31"
|
||||
value={seasonStart.seasonEndDay}
|
||||
onChange={(e) => persistSeason({ ...seasonStart, seasonEndDay: parseInt(e.target.value) || 1 })}
|
||||
className="w-16 rounded-lg border border-zinc-600 px-2 py-1.5 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-zinc-400 mb-1">Recipient name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={recipientName}
|
||||
onChange={(e) => persistRecipientName(e.target.value)}
|
||||
placeholder="David"
|
||||
className="w-full rounded-lg border border-zinc-600 px-3 py-1.5 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-zinc-400 mb-1">Recipient phone</label>
|
||||
<input
|
||||
type="text"
|
||||
value={recipientPhone}
|
||||
onChange={(e) => persistRecipientPhone(e.target.value)}
|
||||
placeholder="+1..."
|
||||
className="w-full rounded-lg border border-zinc-600 px-3 py-1.5 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* ── Headgates ── */}
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-lg font-bold text-zinc-100">Headgates</h2>
|
||||
{!showAddHg && (
|
||||
<button
|
||||
onClick={() => setShowAddHg(true)}
|
||||
className="rounded-lg bg-slate-900 px-3 py-1.5 text-sm font-medium text-white hover:bg-slate-800"
|
||||
>
|
||||
+ Add Headgate
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showAddHg && (
|
||||
<form onSubmit={handleAddHg} className="mb-4 flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newHgName}
|
||||
onChange={(e) => setNewHgName(e.target.value)}
|
||||
placeholder="Headgate name"
|
||||
className="flex-1 rounded-lg border border-zinc-600 px-3 py-2 text-sm outline-none focus:border-slate-900"
|
||||
autoFocus
|
||||
/>
|
||||
<select
|
||||
value={newHgUnit}
|
||||
onChange={(e) => setNewHgUnit(e.target.value)}
|
||||
className="rounded-lg border border-zinc-600 px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="CFS">CFS</option>
|
||||
<option value="GPM">GPM</option>
|
||||
<option value="gal">gal</option>
|
||||
<option value="ac-in">ac-in</option>
|
||||
<option value="ac-ft">ac-ft</option>
|
||||
</select>
|
||||
<button type="submit" disabled={savingHg || !newHgName.trim()} className="rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white disabled:opacity-50">
|
||||
{savingHg ? "..." : "Add"}
|
||||
</button>
|
||||
<button type="button" onClick={() => { setShowAddHg(false); setNewHgName(""); }} className="rounded-lg border border-zinc-600 px-4 py-2 text-sm font-medium text-zinc-300">
|
||||
Cancel
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="rounded-xl bg-zinc-900 shadow-black/20 ring-1 ring-zinc-700 overflow-hidden">
|
||||
{headgates.length === 0 ? (
|
||||
<p className="p-6 text-center text-sm text-zinc-500">No headgates yet</p>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-100 bg-slate-50 text-left">
|
||||
<th className="px-4 py-3 font-semibold text-zinc-400">Name</th>
|
||||
<th className="px-4 py-3 font-semibold text-zinc-400">Unit</th>
|
||||
<th className="px-4 py-3 font-semibold text-zinc-400">Status</th>
|
||||
<th className="px-4 py-3 font-semibold text-zinc-400">Created</th>
|
||||
<th className="px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{headgates.map((hg) => (
|
||||
<tr key={hg.id} className="border-b border-slate-50 last:border-0 hover:bg-zinc-800">
|
||||
<td className="px-4 py-3 font-medium text-zinc-100">{hg.name}</td>
|
||||
<td className="px-4 py-3 text-zinc-300">{hg.unit}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-block rounded-full px-2 py-0.5 text-xs font-semibold ${hg.active ? "bg-green-900/40 text-green-400" : "bg-zinc-950 text-zinc-500"}`}>
|
||||
{hg.active ? "Active" : "Inactive"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-400 text-xs">{formatDateTime(hg.created_at)}</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<button
|
||||
onClick={() => router.push(`/admin/water-log/headgates/${hg.id}`)}
|
||||
className="text-xs text-zinc-500 hover:text-zinc-100"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Water Users ── */}
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-zinc-100">Water Users</h2>
|
||||
<p className="text-xs text-zinc-500 mt-0.5">
|
||||
<span className="font-medium text-blue-400">Admin</span> = manage headgates, users, entries |
|
||||
<span className="font-medium text-green-600">Irrigator</span> = submit entries only
|
||||
</p>
|
||||
</div>
|
||||
{!showAddUser && (
|
||||
<button
|
||||
onClick={() => setShowAddUser(true)}
|
||||
className="rounded-lg bg-slate-900 px-3 py-1.5 text-sm font-medium text-white hover:bg-slate-800"
|
||||
>
|
||||
+ Add User
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showAddUser && (
|
||||
<form onSubmit={handleAddUser} className="mb-4 rounded-xl bg-zinc-900 shadow-black/20 ring-1 ring-zinc-700 p-4 space-y-3">
|
||||
<div className="flex gap-2 items-end">
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs font-medium text-zinc-400 mb-1">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newUserName}
|
||||
onChange={(e) => setNewUserName(e.target.value)}
|
||||
placeholder="Full name"
|
||||
className="w-full rounded-lg border border-zinc-600 px-3 py-2 text-sm outline-none focus:border-slate-900"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-zinc-400 mb-1">Role</label>
|
||||
<select
|
||||
value={newUserRole}
|
||||
onChange={(e) => setNewUserRole(e.target.value as "irrigator" | "water_admin")}
|
||||
className="rounded-lg border border-zinc-600 px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="irrigator">Irrigator</option>
|
||||
<option value="water_admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-zinc-400 mb-1">Language</label>
|
||||
<select
|
||||
value={newUserLang}
|
||||
onChange={(e) => setNewUserLang(e.target.value)}
|
||||
className="rounded-lg border border-zinc-600 px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="en">English</option>
|
||||
<option value="es">Español</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button type="submit" disabled={savingUser || !newUserName.trim()} className="rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white disabled:opacity-50">
|
||||
{savingUser ? "..." : "Add User"}
|
||||
</button>
|
||||
<button type="button" onClick={() => { setShowAddUser(false); setNewUserName(""); setNewUserRole("irrigator"); setNewUserLang("en"); }}
|
||||
className="rounded-lg border border-zinc-600 px-4 py-2 text-sm font-medium text-zinc-300">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* PIN display banner */}
|
||||
{newPin && (
|
||||
<div className="mb-4 rounded-xl bg-amber-900/30 border border-yellow-200 p-4">
|
||||
<p className="font-semibold text-yellow-800">New PIN for {newPin.name}:</p>
|
||||
<p className="mt-1 text-2xl font-bold tracking-widest text-yellow-900">{newPin.pin}</p>
|
||||
<p className="mt-1 text-xs text-yellow-600">Write this down — it will not be shown again.</p>
|
||||
<button onClick={() => setNewPin(null)} className="mt-2 text-xs text-yellow-700 underline">Dismiss</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-xl bg-zinc-900 shadow-black/20 ring-1 ring-zinc-700 overflow-hidden">
|
||||
{users.length === 0 ? (
|
||||
<p className="p-6 text-center text-sm text-zinc-500">No water users yet</p>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-100 bg-slate-50 text-left">
|
||||
<th className="px-4 py-3 font-semibold text-zinc-400">Name</th>
|
||||
<th className="px-4 py-3 font-semibold text-zinc-400">Role</th>
|
||||
<th className="px-4 py-3 font-semibold text-zinc-400">Status</th>
|
||||
<th className="px-4 py-3 font-semibold text-zinc-400 hidden md:table-cell">Language</th>
|
||||
<th className="px-4 py-3 font-semibold text-zinc-400 hidden lg:table-cell">Last Used</th>
|
||||
<th className="px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((user) => (
|
||||
<tr key={user.id} className="border-b border-slate-50 last:border-0 hover:bg-zinc-800">
|
||||
<td className="px-4 py-3 font-medium text-zinc-100">{user.name}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-block rounded-full px-2 py-0.5 text-xs font-semibold ${
|
||||
user.role === "water_admin" ? "bg-blue-900/40 text-blue-700" : "bg-green-900/40 text-green-400"
|
||||
}`}>
|
||||
{user.role === "water_admin" ? "Admin" : "Irrigator"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-block rounded-full px-2 py-0.5 text-xs font-semibold ${user.active ? "bg-green-900/40 text-green-400" : "bg-zinc-950 text-zinc-500"}`}>
|
||||
{user.active ? "Active" : "Inactive"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 hidden md:table-cell text-zinc-500 text-xs">
|
||||
{user.language_preference === "es" ? "Español" : "English"}
|
||||
</td>
|
||||
<td className="px-4 py-3 hidden lg:table-cell text-slate-400 text-xs">{formatDateTime(user.last_used_at)}</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<button
|
||||
onClick={() => router.push(`/admin/water-log/users/${user.id}`)}
|
||||
className="text-xs text-zinc-500 hover:text-zinc-100"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Entries + CSV Export ── */}
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-lg font-bold text-zinc-100">Recent Entries</h2>
|
||||
<button
|
||||
onClick={handleExportCSV}
|
||||
disabled={csvLoading}
|
||||
className="rounded-lg bg-green-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-green-700 disabled:opacity-50"
|
||||
>
|
||||
{csvLoading ? "..." : "Export CSV"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="mb-3 flex flex-wrap gap-2">
|
||||
<input
|
||||
type="date"
|
||||
value={filterDateFrom}
|
||||
onChange={(e) => setFilterDateFrom(e.target.value)}
|
||||
className="rounded-lg border border-zinc-600 px-2 py-1.5 text-sm"
|
||||
placeholder="From"
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
value={filterDateTo}
|
||||
onChange={(e) => setFilterDateTo(e.target.value)}
|
||||
className="rounded-lg border border-zinc-600 px-2 py-1.5 text-sm"
|
||||
placeholder="To"
|
||||
/>
|
||||
<select
|
||||
value={filterHeadgate}
|
||||
onChange={(e) => setFilterHeadgate(e.target.value)}
|
||||
className="rounded-lg border border-zinc-600 px-2 py-1.5 text-sm"
|
||||
>
|
||||
<option value="">All headgates</option>
|
||||
{headgates.map((hg) => (
|
||||
<option key={hg.id} value={hg.id}>{hg.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={filterUser}
|
||||
onChange={(e) => setFilterUser(e.target.value)}
|
||||
className="rounded-lg border border-zinc-600 px-2 py-1.5 text-sm"
|
||||
>
|
||||
<option value="">All users</option>
|
||||
{users.map((u) => (
|
||||
<option key={u.id} value={u.id}>{u.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={filterVia}
|
||||
onChange={(e) => setFilterVia(e.target.value)}
|
||||
className="rounded-lg border border-zinc-600 px-2 py-1.5 text-sm"
|
||||
>
|
||||
<option value="">All via</option>
|
||||
<option value="field">Field</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
{(filterDateFrom || filterDateTo || filterHeadgate || filterUser || filterVia) && (
|
||||
<button
|
||||
onClick={() => { setFilterDateFrom(""); setFilterDateTo(""); setFilterHeadgate(""); setFilterUser(""); setFilterVia(""); }}
|
||||
className="text-xs text-zinc-500 hover:text-zinc-300 underline"
|
||||
>
|
||||
Clear filters
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl bg-zinc-900 shadow-black/20 ring-1 ring-zinc-700 overflow-hidden">
|
||||
{filteredEntries.length === 0 ? (
|
||||
<p className="p-6 text-center text-sm text-zinc-500">No entries{entries.length > 0 ? " match filters" : ""}</p>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-100 bg-slate-50 text-left">
|
||||
<th className="px-4 py-3 font-semibold text-zinc-400">When</th>
|
||||
<th className="px-4 py-3 font-semibold text-zinc-400">User</th>
|
||||
<th className="px-4 py-3 font-semibold text-zinc-400">Headgate</th>
|
||||
<th className="px-4 py-3 font-semibold text-zinc-400 text-right">Measurement</th>
|
||||
<th className="px-4 py-3 font-semibold text-zinc-400">Via</th>
|
||||
<th className="px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredEntries.map((e) => (
|
||||
<tr key={e.id} className="border-b border-slate-50 last:border-0 hover:bg-zinc-800">
|
||||
<td className="px-4 py-3 text-zinc-500 text-xs">{formatDateTime(e.logged_at)}</td>
|
||||
<td className="px-4 py-3 font-medium text-zinc-100">{e.user_name}</td>
|
||||
<td className="px-4 py-3 text-zinc-300">{e.headgate_name}</td>
|
||||
<td className="px-4 py-3 text-right font-semibold text-zinc-100">{e.measurement} {e.unit}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-block rounded-full px-2 py-0.5 text-xs font-semibold ${e.submitted_via === "field" ? "bg-blue-900/40 text-blue-700" : "bg-zinc-950 text-zinc-400"}`}>
|
||||
{e.submitted_via}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<button
|
||||
onClick={() => router.push(`/admin/water-log/entries/${e.id}`)}
|
||||
className="text-xs text-zinc-500 hover:text-zinc-100"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { updateWaterEntry, deleteWaterEntry } from "@/actions/water-log/admin";
|
||||
|
||||
type WaterEntry = {
|
||||
id: string;
|
||||
headgate_id: string;
|
||||
user_id: string;
|
||||
headgate_name: string;
|
||||
user_name: string;
|
||||
measurement: number;
|
||||
unit: string;
|
||||
notes: string | null;
|
||||
submitted_via: string;
|
||||
logged_at: string;
|
||||
headgate_unit?: string;
|
||||
};
|
||||
|
||||
const UNIT_OPTIONS = ["CFS", "GPM", "gal", "ac-in", "ac-ft"];
|
||||
|
||||
type Props = {
|
||||
entry: WaterEntry;
|
||||
brandId: string;
|
||||
backHref?: string;
|
||||
};
|
||||
|
||||
export default function WaterLogEntryEditForm({ entry, backHref = "/admin/water-log" }: Props) {
|
||||
const router = useRouter();
|
||||
const [measurement, setMeasurement] = useState(entry.measurement);
|
||||
const [unit, setUnit] = useState(entry.unit);
|
||||
const [notes, setNotes] = useState(entry.notes ?? "");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleSave(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
const result = await updateWaterEntry(entry.id, measurement, notes || null, unit);
|
||||
if (result.success) {
|
||||
router.push(backHref);
|
||||
} else {
|
||||
setError(result.error ?? "Failed to save");
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!window.confirm(`Delete this entry from ${entry.user_name} at ${entry.headgate_name}? This cannot be undone.`)) return;
|
||||
setDeleting(true);
|
||||
const result = await deleteWaterEntry(entry.id);
|
||||
if (result.success) {
|
||||
router.push(backHref);
|
||||
} else {
|
||||
setError(result.error ?? "Failed to delete");
|
||||
setDeleting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSave} className="space-y-4">
|
||||
{error && (
|
||||
<div className="rounded-lg bg-red-900/30 border border-red-200 p-3 text-sm text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-400 mb-1">Headgate</label>
|
||||
<p className="rounded-lg border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm text-zinc-300">{entry.headgate_name}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-400 mb-1">User</label>
|
||||
<p className="rounded-lg border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm text-zinc-300">{entry.user_name}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-400 mb-1">Measurement</label>
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
value={measurement}
|
||||
onChange={(e) => setMeasurement(parseFloat(e.target.value) || 0)}
|
||||
className="w-full rounded-lg border border-zinc-600 px-3 py-2 text-sm outline-none focus:border-slate-900"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-400 mb-1">Unit</label>
|
||||
<select
|
||||
value={unit}
|
||||
onChange={(e) => setUnit(e.target.value)}
|
||||
className="w-full rounded-lg border border-zinc-600 px-3 py-2 text-sm"
|
||||
>
|
||||
{UNIT_OPTIONS.map((u) => (
|
||||
<option key={u} value={u}>{u}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-400 mb-1">Submitted Via</label>
|
||||
<p className="rounded-lg border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm text-zinc-300">{entry.submitted_via}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-400 mb-1">Logged</label>
|
||||
<p className="rounded-lg border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm text-zinc-300">
|
||||
{new Date(entry.logged_at).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-400 mb-1">Notes</label>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full rounded-lg border border-zinc-600 px-3 py-2 text-sm outline-none focus:border-slate-900 resize-none"
|
||||
placeholder="Optional notes..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDelete}
|
||||
disabled={deleting}
|
||||
className="rounded-lg border border-red-200 bg-zinc-900 px-4 py-2 text-sm font-medium text-red-400 hover:bg-red-900/30 disabled:opacity-50"
|
||||
>
|
||||
{deleting ? "..." : "Delete Entry"}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="rounded-lg bg-green-600 px-6 py-2 text-sm font-medium text-white hover:bg-green-700 disabled:opacity-50"
|
||||
>
|
||||
{saving ? "..." : "Save Changes"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { updateWaterIrrigator, resetWaterIrrigatorPin, deleteWaterUser } from "@/actions/water-log/admin";
|
||||
|
||||
type WaterUser = {
|
||||
id: string;
|
||||
name: string;
|
||||
role: "irrigator" | "water_admin";
|
||||
active: boolean;
|
||||
language_preference: string;
|
||||
last_used_at: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
waterUser: WaterUser;
|
||||
backHref?: string;
|
||||
};
|
||||
|
||||
export default function WaterUserEditForm({ waterUser, backHref = "/admin/water-log" }: Props) {
|
||||
const router = useRouter();
|
||||
const [name, setName] = useState(waterUser.name);
|
||||
const [role, setRole] = useState<"irrigator" | "water_admin">(waterUser.role);
|
||||
const [active, setActive] = useState(waterUser.active);
|
||||
const [lang, setLang] = useState(waterUser.language_preference);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [resetting, setResetting] = useState(false);
|
||||
const [newPin, setNewPin] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleSave(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
const result = await updateWaterIrrigator(waterUser.id, name.trim(), active, lang, role);
|
||||
if (result.success) {
|
||||
router.push(backHref);
|
||||
} else {
|
||||
setError(result.error ?? "Failed to save");
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResetPin() {
|
||||
setResetting(true);
|
||||
const result = await resetWaterIrrigatorPin(waterUser.id);
|
||||
if (result.success && result.pin) {
|
||||
setNewPin(result.pin);
|
||||
} else {
|
||||
setError(result.error ?? "Failed to reset PIN");
|
||||
}
|
||||
setResetting(false);
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!window.confirm(`Delete user "${waterUser.name}"? Their log entries will be preserved.`)) return;
|
||||
setDeleting(true);
|
||||
const result = await deleteWaterUser(waterUser.id);
|
||||
if (result.success) {
|
||||
router.push(backHref);
|
||||
} else {
|
||||
setError(result.error ?? "Failed to delete");
|
||||
setDeleting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSave} className="space-y-4">
|
||||
{error && (
|
||||
<div className="rounded-lg bg-red-900/30 border border-red-200 p-3 text-sm text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{newPin && (
|
||||
<div className="rounded-xl bg-yellow-50 border border-yellow-200 p-4">
|
||||
<p className="font-semibold text-yellow-800">New PIN for {name}:</p>
|
||||
<p className="mt-1 text-2xl font-bold tracking-widest text-yellow-900">{newPin}</p>
|
||||
<p className="mt-1 text-xs text-yellow-600">Write this down — it will not be shown again.</p>
|
||||
<button onClick={() => setNewPin(null)} className="mt-2 text-xs text-yellow-700 underline">Dismiss</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-400 mb-1">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full rounded-lg border border-zinc-600 px-3 py-2 text-sm outline-none focus:border-slate-900"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-400 mb-1">Role</label>
|
||||
<select
|
||||
value={role}
|
||||
onChange={(e) => setRole(e.target.value as "irrigator" | "water_admin")}
|
||||
className="w-full rounded-lg border border-zinc-600 px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="irrigator">Irrigator</option>
|
||||
<option value="water_admin">Admin</option>
|
||||
</select>
|
||||
<p className="text-xs text-slate-400 mt-0.5">
|
||||
{role === "water_admin"
|
||||
? "Can manage headgates, users, and entries"
|
||||
: "Can only submit water log entries"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-400 mb-1">Language</label>
|
||||
<select
|
||||
value={lang}
|
||||
onChange={(e) => setLang(e.target.value)}
|
||||
className="w-full rounded-lg border border-zinc-600 px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="en">English</option>
|
||||
<option value="es">Español</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-400 mb-1">Status</label>
|
||||
<select
|
||||
value={active ? "1" : "0"}
|
||||
onChange={(e) => setActive(e.target.value === "1")}
|
||||
className="rounded-lg border border-zinc-600 px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="1">Active</option>
|
||||
<option value="0">Inactive</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleResetPin}
|
||||
disabled={resetting}
|
||||
className="rounded-lg border border-zinc-600 bg-zinc-900 px-4 py-2 text-sm font-medium text-zinc-300 hover:bg-zinc-900 disabled:opacity-50"
|
||||
>
|
||||
{resetting ? "..." : "Reset PIN"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDelete}
|
||||
disabled={deleting}
|
||||
className="rounded-lg border border-red-200 bg-zinc-900 px-4 py-2 text-sm font-medium text-red-400 hover:bg-red-900/30 disabled:opacity-50"
|
||||
>
|
||||
{deleting ? "..." : "Delete User"}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="rounded-lg bg-green-600 px-6 py-2 text-sm font-medium text-white hover:bg-green-700 disabled:opacity-50"
|
||||
>
|
||||
{saving ? "..." : "Save Changes"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { getRecentWebhookActivity } from "@/actions/wholesale";
|
||||
|
||||
type WebhookLogEntry = {
|
||||
id: string;
|
||||
event_type: string;
|
||||
order_id: string | null;
|
||||
status: string;
|
||||
attempts: number;
|
||||
created_at: string;
|
||||
response: string | null;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
brandId: string;
|
||||
};
|
||||
|
||||
const EVENT_LABELS: Record<string, string> = {
|
||||
order_created: "Order Created",
|
||||
order_fulfilled: "Order Fulfilled",
|
||||
deposit_recorded: "Deposit Recorded",
|
||||
order_paid: "Order Paid",
|
||||
};
|
||||
|
||||
const STATUS_STYLES: Record<string, string> = {
|
||||
sent: "bg-green-900/40 text-green-400",
|
||||
success: "bg-green-900/40 text-green-400",
|
||||
failed: "bg-red-900/40 text-red-400",
|
||||
pending: "bg-yellow-100 text-yellow-700",
|
||||
retrying: "bg-yellow-100 text-yellow-700",
|
||||
};
|
||||
|
||||
export default function WebhookLogsSection({ brandId }: Props) {
|
||||
const [logs, setLogs] = useState<WebhookLogEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
getRecentWebhookActivity(brandId, 25).then((entries) => {
|
||||
setLogs(entries);
|
||||
setLoading(false);
|
||||
});
|
||||
}, [brandId]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="rounded-xl border border-zinc-800 bg-slate-50 p-4 text-sm text-zinc-500">
|
||||
Loading webhook logs…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (logs.length === 0) {
|
||||
return (
|
||||
<div className="rounded-xl border border-zinc-800 bg-slate-50 p-4 text-sm text-zinc-500">
|
||||
No webhook events yet. Deposits, order updates, and fulfillments will appear here.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium text-zinc-300">
|
||||
Wholesale Webhook Events ({logs.length})
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="text-xs text-zinc-500 hover:text-zinc-300"
|
||||
>
|
||||
{expanded ? "Collapse" : "Expand"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
{(expanded ? logs : logs.slice(0, 5)).map((entry) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="flex items-start justify-between gap-3 rounded-xl border border-zinc-800 bg-zinc-900 px-4 py-3"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-medium text-slate-800">
|
||||
{EVENT_LABELS[entry.event_type] ?? entry.event_type}
|
||||
</span>
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-xs font-medium ${
|
||||
STATUS_STYLES[entry.status] ?? "bg-zinc-950 text-zinc-400"
|
||||
}`}
|
||||
>
|
||||
{entry.status}
|
||||
</span>
|
||||
{entry.attempts > 1 && (
|
||||
<span className="text-xs text-slate-400">
|
||||
{entry.attempts} attempts
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{entry.response && (
|
||||
<p className="mt-1 truncate text-xs text-zinc-500">{entry.response}</p>
|
||||
)}
|
||||
<p className="mt-0.5 text-xs text-slate-400">
|
||||
{new Date(entry.created_at).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!expanded && logs.length > 5 && (
|
||||
<button
|
||||
onClick={() => setExpanded(true)}
|
||||
className="text-xs text-zinc-500 hover:text-zinc-300"
|
||||
>
|
||||
Show {logs.length - 5} more…
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { getWelcomeSequence, resendWelcomeEmail, type WelcomeSequenceEntry } from "@/actions/email-automation/welcome-sequence";
|
||||
|
||||
type Props = { brandId: string };
|
||||
|
||||
const STATUS_LABELS: Record<string, { en: string; color: string }> = {
|
||||
active: { en: "Active", color: "bg-blue-900/40 text-blue-400 border border-blue-800" },
|
||||
completed: { en: "Completed", color: "bg-emerald-900/40 text-emerald-400 border border-emerald-800" },
|
||||
unsubscribed: { en: "Unsubscribed", color: "bg-red-900/40 text-red-400 border border-red-800" },
|
||||
bounced: { en: "Bounced", color: "bg-amber-900/40 text-amber-400 border border-amber-800" },
|
||||
};
|
||||
|
||||
const STEP_LABELS: Record<number, string> = {
|
||||
0: "Enrolled",
|
||||
1: "Welcome (1h)",
|
||||
2: "How it works (24h)",
|
||||
3: "First order (48h)",
|
||||
4: "Final (72h)",
|
||||
};
|
||||
|
||||
export default function WelcomeSequenceDashboard({ brandId }: Props) {
|
||||
const [entries, setEntries] = useState<WelcomeSequenceEntry[]>([]);
|
||||
const [stats, setStats] = useState({ total: 0, completed: 0, active: 0, unsubscribed: 0 });
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [filter, setFilter] = useState<"all" | "active" | "completed">("all");
|
||||
const [page, setPage] = useState(0);
|
||||
const PAGE_SIZE = 25;
|
||||
const [resending, setResending] = useState<string | null>(null);
|
||||
|
||||
const handleResend = async (entryId: string) => {
|
||||
setResending(entryId);
|
||||
await resendWelcomeEmail(entryId, brandId);
|
||||
await load();
|
||||
setResending(null);
|
||||
};
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
const result = await getWelcomeSequence(brandId);
|
||||
if (result.success) {
|
||||
setEntries(result.entries);
|
||||
setStats(result.stats);
|
||||
}
|
||||
setLoading(false);
|
||||
}, [brandId]);
|
||||
|
||||
useEffect(() => { setPage(0); }, [filter]);
|
||||
|
||||
const filtered = entries.filter(e => {
|
||||
if (filter === "active") return e.status === "active";
|
||||
if (filter === "completed") return e.status === "completed";
|
||||
return true;
|
||||
});
|
||||
|
||||
const paginatedEntries = filtered.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
|
||||
const totalPages = Math.ceil(filtered.length / PAGE_SIZE);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{[
|
||||
{ label: "Total", value: stats.total, color: "text-zinc-100" },
|
||||
{ label: "Active", value: stats.active, color: "text-blue-400" },
|
||||
{ label: "Completed", value: stats.completed, color: "text-emerald-400" },
|
||||
{ label: "Unsubscribed", value: stats.unsubscribed, color: "text-red-400" },
|
||||
].map(s => (
|
||||
<div key={s.label} className="bg-zinc-900 border border-zinc-800 rounded-2xl p-4 text-center">
|
||||
<p className={`text-2xl font-black ${s.color}`}>{s.value}</p>
|
||||
<p className="text-xs text-zinc-500 uppercase tracking-widest mt-1">{s.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Filter + refresh + pagination */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={load} disabled={loading}
|
||||
className="text-xs font-semibold text-violet-400 hover:text-violet-300 transition-colors">
|
||||
{loading ? "Loading..." : "↻ Refresh"}
|
||||
</button>
|
||||
<div className="flex gap-2 ml-auto">
|
||||
{(["all", "active", "completed"] as const).map(f => (
|
||||
<button key={f} onClick={() => setFilter(f)}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-semibold transition-all ${filter === f ? "bg-violet-600 text-white" : "bg-zinc-800 text-zinc-400 hover:text-zinc-200"}`}>
|
||||
{f.charAt(0).toUpperCase() + f.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center gap-1 ml-2">
|
||||
<button onClick={() => setPage(p => Math.max(0, p - 1))} disabled={page === 0}
|
||||
className="flex h-7 w-7 items-center justify-center rounded border border-zinc-700 text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800 disabled:opacity-30 disabled:cursor-not-allowed transition-colors">
|
||||
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" /></svg>
|
||||
</button>
|
||||
<span className="text-xs text-zinc-500 px-1">{page + 1}/{totalPages}</span>
|
||||
<button onClick={() => setPage(p => Math.min(totalPages - 1, p + 1))} disabled={page >= totalPages - 1}
|
||||
className="flex h-7 w-7 items-center justify-center rounded border border-zinc-700 text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800 disabled:opacity-30 disabled:cursor-not-allowed transition-colors">
|
||||
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
{filtered.length === 0 ? (
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-2xl py-16 text-center">
|
||||
<p className="text-zinc-600 text-sm">No entries{filter !== "all" ? ` (${filter})` : ""}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-2xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-xs text-zinc-500 uppercase tracking-widest border-b border-zinc-800">
|
||||
<th className="text-left px-5 py-3 font-medium">Contact</th>
|
||||
<th className="text-left px-5 py-3 font-medium">Language</th>
|
||||
<th className="text-left px-5 py-3 font-medium">Step</th>
|
||||
<th className="text-left px-5 py-3 font-medium">Status</th>
|
||||
<th className="text-left px-5 py-3 font-medium">Enrolled</th>
|
||||
<th className="text-left px-5 py-3 font-medium">Last Email</th>
|
||||
<th className="text-right px-5 py-3 font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{paginatedEntries.map(e => {
|
||||
const meta = STATUS_LABELS[e.status] ?? { en: e.status, color: "bg-zinc-800 text-zinc-400" };
|
||||
return (
|
||||
<tr key={e.id} className="border-t border-zinc-800/60 hover:bg-zinc-800/30 transition-colors">
|
||||
<td className="px-5 py-3.5">
|
||||
<div>
|
||||
<p className="text-zinc-200 font-medium text-sm">{e.contact_name ?? "—"}</p>
|
||||
<p className="text-zinc-500 text-xs">{e.contact_email}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<span className="text-xs font-mono text-zinc-400 uppercase">{e.locale}</span>
|
||||
</td>
|
||||
<td className="px-5 py-3.5 text-xs text-zinc-400">
|
||||
{STEP_LABELS[e.sequence_step] ?? e.sequence_step}
|
||||
{e.next_email_at && e.status === "active" && (
|
||||
<span className="block text-zinc-600 text-[10px]">Next: {new Date(e.next_email_at).toLocaleString()}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold ${meta.color}`}>
|
||||
{meta.en}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-3.5 text-zinc-500 text-xs">{new Date(e.created_at).toLocaleDateString()}</td>
|
||||
<td className="px-5 py-3.5 text-zinc-500 text-xs">
|
||||
{e.last_email_sent_at ? new Date(e.last_email_sent_at).toLocaleString() : "—"}
|
||||
</td>
|
||||
<td className="px-5 py-3.5 text-right">
|
||||
{e.status !== "unsubscribed" && (
|
||||
<button onClick={() => handleResend(e.id)} disabled={resending === e.id}
|
||||
className="text-xs text-violet-400 hover:text-violet-300 px-2 py-1 rounded-lg hover:bg-violet-900/20 transition-all">
|
||||
{resending === e.id ? "..." : "Resend"}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
type Column<T> = {
|
||||
key: keyof T | string;
|
||||
header: string;
|
||||
render?: (item: T) => React.ReactNode;
|
||||
className?: string;
|
||||
align?: "left" | "right" | "center";
|
||||
};
|
||||
|
||||
type DataTableProps<T> = {
|
||||
data: T[];
|
||||
columns: Column<T>[];
|
||||
keyExtractor: (item: T) => string;
|
||||
emptyMessage?: string;
|
||||
onRowClick?: (item: T) => void;
|
||||
rowClassName?: string;
|
||||
paginate?: boolean;
|
||||
pageSize?: number;
|
||||
};
|
||||
|
||||
export default function DataTable<T>({
|
||||
data,
|
||||
columns,
|
||||
keyExtractor,
|
||||
emptyMessage = "No data found",
|
||||
onRowClick,
|
||||
rowClassName,
|
||||
paginate = false,
|
||||
pageSize = 50,
|
||||
}: DataTableProps<T>) {
|
||||
const [page, setPage] = useState(0);
|
||||
|
||||
const displayed = paginate ? data.slice(page * pageSize, (page + 1) * pageSize) : data;
|
||||
const totalPages = Math.ceil(data.length / pageSize);
|
||||
|
||||
const alignClass = (align?: "left" | "right" | "center") => {
|
||||
switch (align) {
|
||||
case "right": return "text-right";
|
||||
case "center": return "text-center";
|
||||
default: return "text-left";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-lg bg-white border border-stone-200 shadow-sm">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-stone-200 bg-stone-50">
|
||||
{columns.map((col) => (
|
||||
<th
|
||||
key={String(col.key)}
|
||||
className={`px-4 py-3 font-semibold text-stone-500 ${alignClass(col.align)} ${col.className ?? ""}`}
|
||||
>
|
||||
{col.header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{displayed.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={columns.length} className="px-4 py-16 text-center text-sm text-stone-500">
|
||||
{emptyMessage}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
displayed.map((item) => (
|
||||
<tr
|
||||
key={keyExtractor(item)}
|
||||
className={`border-b border-stone-100 last:border-0 hover:bg-stone-50 transition-colors ${
|
||||
onRowClick ? "cursor-pointer" : ""
|
||||
} ${rowClassName ?? ""}`}
|
||||
onClick={() => onRowClick?.(item)}
|
||||
>
|
||||
{columns.map((col) => (
|
||||
<td
|
||||
key={String(col.key)}
|
||||
className={`px-4 py-3 ${alignClass(col.align)} ${col.className ?? ""}`}
|
||||
>
|
||||
{col.render
|
||||
? col.render(item)
|
||||
: String((item as Record<string, unknown>)[col.key as string] ?? "")}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{paginate && totalPages > 1 && (
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-stone-200">
|
||||
<span className="text-xs text-stone-500">
|
||||
Page {page + 1} of {totalPages}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||
disabled={page === 0}
|
||||
className="flex h-7 w-7 items-center justify-center rounded border border-stone-200 text-stone-500 hover:text-stone-700 hover:bg-stone-50 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
|
||||
disabled={page >= totalPages - 1}
|
||||
className="flex h-7 w-7 items-center justify-center rounded border border-stone-200 text-stone-500 hover:text-stone-700 hover:bg-stone-50 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
"use client";
|
||||
|
||||
type FilterOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
type FilterBarProps = {
|
||||
searchPlaceholder?: string;
|
||||
searchValue: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
filters?: {
|
||||
label?: string;
|
||||
options: FilterOption[];
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}[];
|
||||
tabs?: {
|
||||
label: string;
|
||||
value: string;
|
||||
count?: number;
|
||||
}[];
|
||||
activeTab?: string;
|
||||
onTabChange?: (value: string) => void;
|
||||
actions?: React.ReactNode;
|
||||
resultCount?: number;
|
||||
showCount?: boolean;
|
||||
};
|
||||
|
||||
export default function FilterBar({
|
||||
searchPlaceholder = "Search...",
|
||||
searchValue,
|
||||
onSearchChange,
|
||||
filters = [],
|
||||
tabs = [],
|
||||
activeTab,
|
||||
onTabChange,
|
||||
actions,
|
||||
resultCount,
|
||||
showCount = true,
|
||||
}: FilterBarProps) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Search + Filters Row */}
|
||||
<div className="flex flex-wrap gap-3 items-end">
|
||||
<div className="flex-1 min-w-48">
|
||||
<input
|
||||
type="search"
|
||||
placeholder={searchPlaceholder}
|
||||
value={searchValue}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="w-full rounded-lg border border-stone-200 bg-white px-3 py-2 text-sm text-stone-900 outline-none focus:border-emerald-500 placeholder:text-stone-400 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{filters.map((filter, i) => (
|
||||
<div key={i} className="space-y-1">
|
||||
{filter.label && (
|
||||
<label className="text-xs text-stone-500 font-medium pl-1">{filter.label}</label>
|
||||
)}
|
||||
<select
|
||||
value={filter.value}
|
||||
onChange={(e) => filter.onChange(e.target.value)}
|
||||
className="rounded-lg border border-stone-200 bg-white px-3 py-2 text-sm text-stone-700 outline-none focus:border-emerald-500 transition-colors min-w-[140px]"
|
||||
>
|
||||
{filter.options.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{actions && <div className="flex items-center gap-2">{actions}</div>}
|
||||
|
||||
{showCount && resultCount !== undefined && (
|
||||
<span className="text-sm text-stone-500 px-2 py-2">{resultCount} results</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tabs Row */}
|
||||
{tabs.length > 0 && (
|
||||
<div className="flex gap-1 border-b border-stone-200 pb-0">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.value}
|
||||
onClick={() => onTabChange?.(tab.value)}
|
||||
className={`
|
||||
px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px
|
||||
${activeTab === tab.value
|
||||
? "text-emerald-600 border-emerald-600"
|
||||
: "text-stone-500 border-transparent hover:text-stone-700 hover:border-stone-300"
|
||||
}
|
||||
`}
|
||||
>
|
||||
{tab.label}
|
||||
{tab.count !== undefined && (
|
||||
<span className={`ml-2 text-xs ${activeTab === tab.value ? "text-emerald-500" : "text-stone-400"}`}>
|
||||
{tab.count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
type PageHeaderProps = {
|
||||
breadcrumb?: { label: string; href?: string }[];
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: React.ReactNode;
|
||||
};
|
||||
|
||||
export default function PageHeader({ breadcrumb, title, description, action }: PageHeaderProps) {
|
||||
return (
|
||||
<div className="mb-8">
|
||||
{breadcrumb && breadcrumb.length > 0 && (
|
||||
<nav className="flex items-center gap-2 text-xs text-stone-500 mb-4">
|
||||
{breadcrumb.map((crumb, i) => (
|
||||
<span key={i} className="flex items-center gap-2">
|
||||
{crumb.href ? (
|
||||
<Link href={crumb.href} className="hover:text-stone-700 transition-colors">
|
||||
{crumb.label}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-stone-600">{crumb.label}</span>
|
||||
)}
|
||||
{i < breadcrumb.length - 1 && <span>/</span>}
|
||||
</span>
|
||||
))}
|
||||
</nav>
|
||||
)}
|
||||
<div className="flex items-end justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-stone-950 tracking-tight">{title}</h1>
|
||||
{description && (
|
||||
<p className="mt-1.5 text-sm text-stone-500">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
{action && <div>{action}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
type StatusBadgeProps = {
|
||||
status: string;
|
||||
size?: "sm" | "md";
|
||||
};
|
||||
|
||||
const STATUS_STYLES: Record<string, { bg: string; text: string; label: string }> = {
|
||||
// Active/Enabled states
|
||||
active: { bg: "bg-emerald-100", text: "text-emerald-700", label: "Active" },
|
||||
enabled: { bg: "bg-emerald-100", text: "text-emerald-700", label: "Enabled" },
|
||||
published: { bg: "bg-emerald-100", text: "text-emerald-700", label: "Published" },
|
||||
completed: { bg: "bg-emerald-100", text: "text-emerald-700", label: "Completed" },
|
||||
picked_up: { bg: "bg-emerald-100", text: "text-emerald-700", label: "Picked Up" },
|
||||
delivered: { bg: "bg-emerald-100", text: "text-emerald-700", label: "Delivered" },
|
||||
|
||||
// Pending/Warning states
|
||||
pending: { bg: "bg-amber-100", text: "text-amber-700", label: "Pending" },
|
||||
draft: { bg: "bg-amber-100", text: "text-amber-700", label: "Draft" },
|
||||
processing: { bg: "bg-amber-100", text: "text-amber-700", label: "Processing" },
|
||||
open: { bg: "bg-amber-100", text: "text-amber-700", label: "Open" },
|
||||
in_transit: { bg: "bg-amber-100", text: "text-amber-700", label: "In Transit" },
|
||||
|
||||
// Inactive/Disabled states
|
||||
inactive: { bg: "bg-stone-100", text: "text-stone-600", label: "Inactive" },
|
||||
disabled: { bg: "bg-stone-100", text: "text-stone-600", label: "Disabled" },
|
||||
archived: { bg: "bg-stone-100", text: "text-stone-600", label: "Archived" },
|
||||
|
||||
// Info states
|
||||
at_shed: { bg: "bg-blue-100", text: "text-blue-700", label: "At Shed" },
|
||||
packed: { bg: "bg-purple-100", text: "text-purple-700", label: "Packed" },
|
||||
square: { bg: "bg-purple-100", text: "text-purple-700", label: "Square" },
|
||||
|
||||
// Special states
|
||||
core: { bg: "bg-emerald-50", text: "text-emerald-600", label: "Core" },
|
||||
addon: { bg: "bg-amber-50", text: "text-amber-600", label: "Add-on" },
|
||||
};
|
||||
|
||||
export default function StatusBadge({ status, size = "md" }: StatusBadgeProps) {
|
||||
const config = STATUS_STYLES[status] ?? {
|
||||
bg: "bg-stone-100",
|
||||
text: "text-stone-600",
|
||||
label: status.replace(/_/g, " "),
|
||||
};
|
||||
|
||||
const padding = size === "sm" ? "px-2 py-0.5 text-[10px]" : "px-3 py-1 text-xs";
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center rounded-full border font-medium ${config.bg} ${config.text} border-transparent ${padding}`}>
|
||||
{config.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { default as PageHeader } from "./PageHeader";
|
||||
export { default as StatusBadge } from "./StatusBadge";
|
||||
export { default as FilterBar } from "./FilterBar";
|
||||
export { default as DataTable } from "./DataTable";
|
||||
Reference in New Issue
Block a user