feat(admin): design system audit fixes
- Add CSS design tokens for consistency (--admin-danger-hover, --admin-accent-dot, shadow-md warm tone) - Replace unicode icons with inline SVG (⋮, ✕, ▼, ▶) - Consolidate duplicate PageHeader/AdminPageHeader components - Standardize border-radius (rounded-xl → rounded-lg for ViewModeTabs) - Remove hardcoded brand UUID in products page - Replace hardcoded bg-red-600 with CSS variables in delete buttons - Add AdminButton, AdminFilterTabs, AdminSearchInput, PageHeader design system components - Fix GlassModal and AdminModal close button styling - Remove duplicate 'New Lot' button on Route Trace dashboard
This commit is contained in:
@@ -1,3 +1,8 @@
|
||||
/**
|
||||
* @deprecated Use AdminSidebar instead. AdminHeader is deprecated and will be removed.
|
||||
* The AdminSidebar provides the complete navigation system for admin pages.
|
||||
* It includes an expandable Settings sub-menu with all settings pages.
|
||||
*/
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
@@ -4,6 +4,8 @@ import { useState, useMemo } from "react";
|
||||
import Link from "next/link";
|
||||
import { markPickupComplete } from "@/actions/pickup";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import AdminBadge from "./design-system/AdminBadge";
|
||||
import { AdminButton, AdminSearchInput, AdminFilterTabs } from "./design-system";
|
||||
|
||||
type OrderItem = {
|
||||
id: string;
|
||||
@@ -62,12 +64,6 @@ function formatCurrency(amount: number) {
|
||||
|
||||
// Icons
|
||||
const Icons = {
|
||||
search: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="11" cy="11" r="8"/>
|
||||
<path d="m21 21-4.3-4.3"/>
|
||||
</svg>
|
||||
),
|
||||
mapPin: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M15 10.5a3 3 0 1 1-6 0 3 3 0 0 1 6 0z"/>
|
||||
@@ -180,7 +176,7 @@ export default function AdminOrdersPanel({
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-emerald-600">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-[var(--admin-accent)]">
|
||||
{Icons.package("h-5 w-5 text-white")}
|
||||
</div>
|
||||
<div>
|
||||
@@ -191,7 +187,7 @@ export default function AdminOrdersPanel({
|
||||
</div>
|
||||
</div>
|
||||
{brandId && (
|
||||
<span className="rounded-full bg-violet-50 border border-violet-200 px-2.5 py-1 text-xs font-medium text-violet-700">Brand scoped</span>
|
||||
<AdminBadge variant="info">Brand scoped</AdminBadge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -207,40 +203,32 @@ export default function AdminOrdersPanel({
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-4">
|
||||
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium">Picked Up</p>
|
||||
<p className="text-xl sm:text-2xl font-bold text-emerald-600 mt-1">{pickedUpCount}</p>
|
||||
<p className="text-xl sm:text-2xl font-bold text-[var(--admin-accent)] mt-1">{pickedUpCount}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-col sm:flex-row gap-3 mb-6">
|
||||
{/* Status Tabs */}
|
||||
<div className="flex rounded-xl border border-[var(--admin-border)] bg-white p-1">
|
||||
{(["all", "pending", "picked_up"] as StatusTab[]).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => { setActiveTab(tab); setPage(0); }}
|
||||
className={`rounded-lg px-4 py-2 text-sm font-medium transition-colors ${
|
||||
activeTab === tab
|
||||
? "bg-emerald-600 text-white"
|
||||
: "text-stone-500 hover:bg-stone-50"
|
||||
}`}
|
||||
>
|
||||
{tab === "all" ? "All" : tab === "pending" ? "Pending" : "Picked Up"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<AdminFilterTabs
|
||||
activeTab={activeTab}
|
||||
onTabChange={(value) => { setActiveTab(value as StatusTab); setPage(0); }}
|
||||
tabs={[
|
||||
{ value: "all", label: "All", count: filteredOrders.length },
|
||||
{ value: "pending", label: "Pending", count: pendingCount },
|
||||
{ value: "picked_up", label: "Picked Up", count: pickedUpCount },
|
||||
]}
|
||||
size="md"
|
||||
/>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative flex-1">
|
||||
{Icons.search("absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-stone-400")}
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search name, phone, or order #..."
|
||||
value={search}
|
||||
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
|
||||
className="w-full pl-10 pr-4 py-2.5 text-sm border border-[var(--admin-border)] rounded-xl bg-white text-[var(--admin-text-primary)] placeholder:text-stone-400 focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
<AdminSearchInput
|
||||
placeholder="Search name, phone, or order #..."
|
||||
value={search}
|
||||
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
|
||||
onClear={() => { setSearch(""); setPage(0); }}
|
||||
showClear={true}
|
||||
/>
|
||||
|
||||
{/* Stop Filter */}
|
||||
<div className="relative">
|
||||
@@ -248,14 +236,14 @@ export default function AdminOrdersPanel({
|
||||
onClick={() => setShowStopDropdown(!showStopDropdown)}
|
||||
className={`flex items-center gap-2 rounded-xl border px-4 py-2.5 text-sm font-medium transition-colors ${
|
||||
selectedStops.length > 0
|
||||
? "border-emerald-200 bg-emerald-50 text-emerald-700"
|
||||
? "border-[var(--admin-accent)] bg-[var(--admin-accent-light)] text-[var(--admin-accent-text)]"
|
||||
: "border-[var(--admin-border)] bg-white text-stone-600 hover:bg-stone-50"
|
||||
}`}
|
||||
>
|
||||
{Icons.mapPin("h-4 w-4")}
|
||||
<span>Stops</span>
|
||||
{selectedStops.length > 0 && (
|
||||
<span className="rounded-full bg-emerald-600 text-white px-1.5 py-0.5 text-xs font-semibold">{selectedStops.length}</span>
|
||||
<span className="rounded-full bg-[var(--admin-accent)] text-white px-1.5 py-0.5 text-xs font-semibold">{selectedStops.length}</span>
|
||||
)}
|
||||
{Icons.chevronDown("h-4 w-4")}
|
||||
</button>
|
||||
@@ -266,7 +254,7 @@ export default function AdminOrdersPanel({
|
||||
<div className="absolute right-0 top-full mt-2 z-20 w-72 rounded-xl border border-[var(--admin-border)] bg-white shadow-lg">
|
||||
<div className="flex items-center justify-between border-b border-stone-100 px-4 py-3">
|
||||
<span className="text-sm font-semibold text-stone-700">Filter by Stop</span>
|
||||
<button onClick={clearStops} className="text-xs text-emerald-600 hover:text-emerald-700 font-medium">Clear all</button>
|
||||
<button onClick={clearStops} className="text-xs text-[var(--admin-accent)] hover:text-[var(--admin-accent-hover)] font-medium">Clear all</button>
|
||||
</div>
|
||||
<div className="max-h-64 overflow-y-auto p-2">
|
||||
{stops.map((stop) => (
|
||||
@@ -275,7 +263,7 @@ export default function AdminOrdersPanel({
|
||||
type="checkbox"
|
||||
checked={selectedStops.includes(stop.id)}
|
||||
onChange={() => toggleStop(stop.id)}
|
||||
className="h-4 w-4 rounded border-stone-300 text-emerald-600 focus:ring-emerald-500"
|
||||
className="h-4 w-4 rounded border-stone-300 text-[var(--admin-accent)] focus:ring-[var(--admin-accent)]"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<span className="text-sm font-medium text-stone-700">{stop.city}, {stop.state}</span>
|
||||
@@ -297,9 +285,9 @@ export default function AdminOrdersPanel({
|
||||
const stop = stops.find((s) => s.id === stopId);
|
||||
if (!stop) return null;
|
||||
return (
|
||||
<span key={stopId} className="inline-flex items-center gap-1 rounded-full bg-emerald-50 border border-emerald-200 px-3 py-1 text-xs font-medium text-emerald-700">
|
||||
<span key={stopId} className="inline-flex items-center gap-1 rounded-full bg-[var(--admin-accent-light)] border border-[var(--admin-accent)] px-3 py-1 text-xs font-medium text-[var(--admin-accent-text)]">
|
||||
{stop.city}, {stop.state}
|
||||
<button onClick={() => toggleStop(stopId)} className="ml-1 hover:text-emerald-900">
|
||||
<button onClick={() => toggleStop(stopId)} className="ml-1 hover:text-[var(--admin-accent-hover)]">
|
||||
{Icons.x("h-3 w-3")}
|
||||
</button>
|
||||
</span>
|
||||
@@ -335,7 +323,7 @@ export default function AdminOrdersPanel({
|
||||
{paginatedOrders.map((order) => (
|
||||
<tr key={order.id} className="hover:bg-stone-50 transition-colors">
|
||||
<td className="px-4 py-3">
|
||||
<Link href={`/admin/orders/${order.id}`} className="font-mono text-xs text-emerald-600 hover:text-emerald-700">
|
||||
<Link href={`/admin/orders/${order.id}`} className="font-mono text-xs text-[var(--admin-accent)] hover:text-[var(--admin-accent-hover)]">
|
||||
{shortId(order.id)}
|
||||
</Link>
|
||||
</td>
|
||||
@@ -362,12 +350,12 @@ export default function AdminOrdersPanel({
|
||||
<td className="px-4 py-3 text-center">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
{order.pickup_complete ? (
|
||||
<span className="rounded-full bg-emerald-100 text-emerald-700 px-2.5 py-0.5 text-[10px] font-semibold">Picked Up</span>
|
||||
<AdminBadge variant="success" dot>Picked Up</AdminBadge>
|
||||
) : (
|
||||
<span className="rounded-full bg-amber-100 text-amber-700 px-2.5 py-0.5 text-[10px] font-semibold">Pending</span>
|
||||
<AdminBadge variant="warning" dot>Pending</AdminBadge>
|
||||
)}
|
||||
{order.payment_processor === "square" && (
|
||||
<span className="rounded-full bg-violet-100 text-violet-700 px-1.5 py-0.5 text-[10px] font-semibold">Square</span>
|
||||
<AdminBadge variant="info">Square</AdminBadge>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
@@ -406,15 +394,15 @@ export default function AdminOrdersPanel({
|
||||
|
||||
{/* Toast */}
|
||||
{pickupToast && (
|
||||
<div className="fixed bottom-6 right-6 z-50 rounded-xl border border-emerald-200 bg-emerald-50 px-5 py-3 shadow-lg">
|
||||
<div className="fixed bottom-6 right-6 z-50 rounded-xl border border-[var(--admin-accent)] bg-[var(--admin-accent-light)] px-5 py-3 shadow-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-emerald-100">
|
||||
{Icons.check("h-4 w-4 text-emerald-600")}
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-[var(--admin-accent)]">
|
||||
{Icons.check("h-4 w-4 text-white")}
|
||||
</div>
|
||||
<span className="font-medium text-emerald-800">Pickup confirmed!</span>
|
||||
<span className="font-medium text-[var(--admin-accent-text)]">Pickup confirmed!</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useRouter } from "next/navigation";
|
||||
@@ -21,7 +21,23 @@ const TOP_LINKS = [
|
||||
{ href: "/admin/advanced", label: "Advanced", icon: "advanced" },
|
||||
];
|
||||
|
||||
const SETTINGS_SUB_LINKS: never[] = [];
|
||||
type SettingsItem = {
|
||||
href: string;
|
||||
label: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
const SETTINGS_SUB_LINKS: 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" },
|
||||
{ href: "/admin/communications/welcome-sequence", label: "Welcome Sequence", description: "4-email onboarding for new subscribers" },
|
||||
];
|
||||
|
||||
function GridIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
@@ -132,6 +148,7 @@ 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"
|
||||
@@ -144,6 +161,19 @@ export default function AdminSidebar({ userRole }: SidebarProps) {
|
||||
return pathname.startsWith(href);
|
||||
};
|
||||
|
||||
// Check if we're on a settings-related page
|
||||
const isSettingsPage = pathname.startsWith("/admin/settings") ||
|
||||
pathname.startsWith("/admin/users") ||
|
||||
pathname.startsWith("/admin/communications/abandoned-carts") ||
|
||||
pathname.startsWith("/admin/communications/welcome-sequence");
|
||||
|
||||
// Auto-expand settings menu when navigating to a settings page
|
||||
useEffect(() => {
|
||||
if (isSettingsPage) {
|
||||
setSettingsOpen(true);
|
||||
}
|
||||
}, [isSettingsPage]);
|
||||
|
||||
async function handleLogout() {
|
||||
document.cookie = "dev_session=;path=/;max-age=0";
|
||||
await supabase.auth.signOut();
|
||||
@@ -210,6 +240,64 @@ export default function AdminSidebar({ userRole }: SidebarProps) {
|
||||
<nav className="flex-1 overflow-y-auto px-3 py-5 space-y-1">
|
||||
{TOP_LINKS.map((item) => {
|
||||
const active = isActive(item.href);
|
||||
|
||||
// Settings link with expandable sub-menu
|
||||
if (item.href === "/admin/settings") {
|
||||
return (
|
||||
<div key={item.href}>
|
||||
<button
|
||||
onClick={() => setSettingsOpen(!settingsOpen)}
|
||||
className={[
|
||||
"w-full group flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all duration-200 border-l-2",
|
||||
isSettingsPage
|
||||
? "border-l-2"
|
||||
: "border-l-2 border-transparent",
|
||||
].join(" ")}
|
||||
style={isSettingsPage ? {
|
||||
backgroundColor: "rgba(202, 117, 67, 0.15)",
|
||||
color: "#dea889",
|
||||
borderColor: "var(--admin-accent)"
|
||||
} : {
|
||||
color: "var(--admin-sidebar-text)",
|
||||
borderColor: "transparent"
|
||||
}}
|
||||
>
|
||||
<span className="flex-shrink-0 transition-colors" style={{
|
||||
color: isSettingsPage ? "var(--admin-accent)" : "rgba(208, 203, 180, 0.6)"
|
||||
}}>
|
||||
<SettingsCogIcon open={settingsOpen} />
|
||||
</span>
|
||||
Settings
|
||||
<ChevronIcon open={settingsOpen} />
|
||||
</button>
|
||||
|
||||
{/* Settings sub-links */}
|
||||
{settingsOpen && (
|
||||
<div className="ml-6 mt-1 space-y-0.5 border-l border-[var(--admin-sidebar-bg)] pl-3">
|
||||
{SETTINGS_SUB_LINKS.map((subItem) => {
|
||||
const subActive = pathname === subItem.href || pathname.startsWith(subItem.href + "/");
|
||||
return (
|
||||
<Link
|
||||
key={subItem.href}
|
||||
href={subItem.href}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className={[
|
||||
"block px-3 py-2 rounded-lg text-xs font-medium transition-all duration-200",
|
||||
subActive
|
||||
? "bg-emerald-500/10 text-emerald-400"
|
||||
: "text-[var(--admin-sidebar-text)] hover:bg-white/5 opacity-70 hover:opacity-100",
|
||||
].join(" ")}
|
||||
>
|
||||
{subItem.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
@@ -221,16 +309,16 @@ export default function AdminSidebar({ userRole }: SidebarProps) {
|
||||
? "border-l-2"
|
||||
: "border-l-2 border-transparent hover:border-l-2",
|
||||
].join(" ")}
|
||||
style={active ? {
|
||||
backgroundColor: "rgba(202, 117, 67, 0.15)",
|
||||
style={active ? {
|
||||
backgroundColor: "rgba(202, 117, 67, 0.15)",
|
||||
color: "#dea889",
|
||||
borderColor: "var(--admin-accent)"
|
||||
} : {
|
||||
} : {
|
||||
color: "var(--admin-sidebar-text)",
|
||||
borderColor: "transparent"
|
||||
}}
|
||||
>
|
||||
<span className="flex-shrink-0 transition-colors" style={{
|
||||
<span className="flex-shrink-0 transition-colors" style={{
|
||||
color: active ? "var(--admin-accent)" : "rgba(208, 203, 180, 0.6)"
|
||||
}}>
|
||||
{ICON_MAP[item.icon]}
|
||||
|
||||
@@ -257,9 +257,11 @@ export default function AdminStopsPanel({ stops, brandId }: Props) {
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => setOpenMenu(openMenu === stop.id ? null : stop.id)}
|
||||
className="rounded-lg px-2 py-1.5 text-stone-400 hover:text-stone-600 hover:bg-stone-100 transition-colors"
|
||||
className="rounded-lg px-2 py-1.5 text-[var(--admin-text-muted)] hover:text-stone-600 hover:bg-stone-100 transition-colors"
|
||||
>
|
||||
⋮
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{openMenu === stop.id && (
|
||||
@@ -303,7 +305,7 @@ export default function AdminStopsPanel({ stops, brandId }: Props) {
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(stop.id)}
|
||||
className="flex-1 rounded-lg bg-red-600 px-3 py-2 text-xs font-medium text-white hover:bg-red-500"
|
||||
className="flex-1 rounded-lg bg-[var(--admin-danger)] px-3 py-2 text-xs font-medium text-white hover:bg-[var(--admin-danger-hover)]"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
|
||||
@@ -83,49 +83,36 @@ export default function AdvancedSettingsClient({ brandId, brands, stripeConnect
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--admin-bg)]">
|
||||
{/* Header */}
|
||||
<div className="px-4 sm:px-6 md:px-8 pt-4 sm:pt-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="flex h-10 w-10 sm:h-12 sm:w-12 items-center justify-center rounded-xl bg-violet-600">
|
||||
{Icons.sparkles("h-5 w-5 sm:h-6 sm:w-6 text-white")}
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl sm:text-2xl font-black text-[var(--admin-text-primary)] tracking-tight">Advanced</h1>
|
||||
<p className="text-xs text-[var(--admin-text-muted)]">Developer settings, APIs, and integrations</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab navigation */}
|
||||
<nav className="grid grid-cols-6 gap-1 p-1.5 rounded-xl bg-white border border-stone-200">
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`relative flex flex-col sm:flex-row items-center justify-center gap-1 sm:gap-2 rounded-lg px-1 sm:px-3 py-2 text-[9px] sm:text-xs font-semibold transition-colors ${
|
||||
activeTab === tab.id
|
||||
? "bg-violet-600 text-white"
|
||||
: "text-stone-500 hover:text-stone-700 hover:bg-stone-50"
|
||||
}`}
|
||||
>
|
||||
{tab.icon === "plug" && Icons.plug("h-4 w-4")}
|
||||
{tab.icon === "sparkles" && Icons.sparkles("h-4 w-4")}
|
||||
{tab.icon === "square" && Icons.square("h-4 w-4")}
|
||||
{tab.icon === "truck" && Icons.truck("h-4 w-4")}
|
||||
{tab.icon === "webhook" && Icons.webhook("h-4 w-4")}
|
||||
{tab.icon === "credit-card" && Icons["credit-card"]("h-4 w-4")}
|
||||
<span className="hidden sm:inline">{tab.label}</span>
|
||||
<span className="sm:hidden">{tab.label.substring(0, 3)}</span>
|
||||
{activeTab === tab.id && (
|
||||
<div className="absolute bottom-1 sm:bottom-1.5 left-1/2 -translate-x-1/2 h-0.5 w-6 sm:w-8 bg-white rounded-full" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
<div>
|
||||
{/* Tab navigation */}
|
||||
<nav className="grid grid-cols-6 gap-1 p-1.5 rounded-xl bg-white border border-[var(--admin-border)]">
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`relative flex flex-col sm:flex-row items-center justify-center gap-1 sm:gap-2 rounded-lg px-1 sm:px-3 py-2 text-[9px] sm:text-xs font-semibold transition-colors ${
|
||||
activeTab === tab.id
|
||||
? "bg-[var(--admin-accent)] text-white"
|
||||
: "text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg)]"
|
||||
}`}
|
||||
>
|
||||
{tab.icon === "plug" && Icons.plug("h-4 w-4")}
|
||||
{tab.icon === "sparkles" && Icons.sparkles("h-4 w-4")}
|
||||
{tab.icon === "square" && Icons.square("h-4 w-4")}
|
||||
{tab.icon === "truck" && Icons.truck("h-4 w-4")}
|
||||
{tab.icon === "webhook" && Icons.webhook("h-4 w-4")}
|
||||
{tab.icon === "credit-card" && Icons["credit-card"]("h-4 w-4")}
|
||||
<span className="hidden sm:inline">{tab.label}</span>
|
||||
<span className="sm:hidden">{tab.label.substring(0, 3)}</span>
|
||||
{activeTab === tab.id && (
|
||||
<div className="absolute bottom-1 sm:bottom-1.5 left-1/2 -translate-x-1/2 h-0.5 w-6 sm:w-8 bg-white rounded-full" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-4 sm:px-6 md:px-8 py-4 sm:py-6">
|
||||
<div className="py-4 sm:py-6">
|
||||
{activeTab === "integrations" && (
|
||||
<AdvancedIntegrations brandId={brandId} brands={brands} />
|
||||
)}
|
||||
@@ -147,8 +134,8 @@ export default function AdvancedSettingsClient({ brandId, brands, stripeConnect
|
||||
{/* Page Header */}
|
||||
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-stone-100">
|
||||
{Icons.webhook("h-6 w-6 text-stone-600")}
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-[var(--admin-bg)]">
|
||||
{Icons.webhook("h-6 w-6 text-[var(--admin-text-muted)]")}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h2 className="text-lg font-bold text-[var(--admin-text-primary)]">Webhooks</h2>
|
||||
@@ -161,7 +148,7 @@ export default function AdvancedSettingsClient({ brandId, brands, stripeConnect
|
||||
|
||||
{/* Coming Soon Card */}
|
||||
<div className="rounded-xl border border-[var(--admin-border)] bg-white overflow-hidden">
|
||||
<div className="bg-stone-50/50 px-6 py-4 border-b border-[var(--admin-border)]">
|
||||
<div className="bg-[var(--admin-bg)]/50 px-6 py-4 border-b border-[var(--admin-border)]">
|
||||
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">Available Webhook Events</h3>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
@@ -185,9 +172,9 @@ export default function AdvancedSettingsClient({ brandId, brands, stripeConnect
|
||||
{ icon: "M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4", label: "Inventory Updates", desc: "Real-time stock changes" },
|
||||
{ icon: "M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1", label: "Custom Webhook URLs", desc: "Send events to your endpoint" },
|
||||
].map((event, i) => (
|
||||
<div key={i} className="flex items-center gap-3 p-3 rounded-lg bg-stone-50/50 border border-stone-100">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-md bg-white border border-stone-200">
|
||||
<svg className="h-4 w-4 text-stone-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<div key={i} className="flex items-center gap-3 p-3 rounded-lg bg-[var(--admin-bg)]/50 border border-[var(--admin-border)]">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-md bg-white border border-[var(--admin-border)]">
|
||||
<svg className="h-4 w-4 text-[var(--admin-text-muted)]" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d={event.icon}/>
|
||||
</svg>
|
||||
</div>
|
||||
@@ -199,23 +186,23 @@ export default function AdvancedSettingsClient({ brandId, brands, stripeConnect
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 p-4 rounded-xl bg-gradient-to-r from-stone-50 to-stone-100/50 border border-stone-200">
|
||||
<div className="mt-6 p-4 rounded-xl bg-gradient-to-r from-[var(--admin-bg)] to-[var(--admin-bg)]/50 border border-[var(--admin-border)]">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="h-4 w-4 text-emerald-600" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<svg className="h-4 w-4 text-green-600" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
<span className="text-sm text-stone-600">Webhook configuration will allow you to receive events via HTTP POST to your custom endpoint.</span>
|
||||
<span className="text-sm text-[var(--admin-text-secondary)]">Webhook configuration will allow you to receive events via HTTP POST to your custom endpoint.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex items-center justify-between pt-4 border-t border-stone-100">
|
||||
<div className="mt-6 flex items-center justify-between pt-4 border-t border-[var(--admin-border)]">
|
||||
<p className="text-xs text-[var(--admin-text-muted)]">
|
||||
Need webhooks now?{" "}
|
||||
<button className="text-emerald-600 hover:text-emerald-700 font-medium">
|
||||
<button className="text-green-600 hover:text-green-700 font-medium">
|
||||
Contact support
|
||||
</button>
|
||||
</p>
|
||||
<button className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-emerald-600 text-white text-sm font-medium hover:bg-emerald-700 transition-colors">
|
||||
<button className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-green-600 text-white text-sm font-medium hover:bg-green-700 transition-colors">
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"/>
|
||||
</svg>
|
||||
@@ -228,8 +215,8 @@ export default function AdvancedSettingsClient({ brandId, brands, stripeConnect
|
||||
{/* Technical Info Card */}
|
||||
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-6">
|
||||
<h4 className="text-sm font-semibold text-[var(--admin-text-primary)] mb-3">Webhook Payload Format</h4>
|
||||
<div className="bg-stone-900 rounded-lg p-4 overflow-x-auto">
|
||||
<pre className="text-xs text-stone-300 font-mono leading-relaxed">
|
||||
<div className="bg-[var(--admin-text-primary)] rounded-lg p-4 overflow-x-auto">
|
||||
<pre className="text-xs text-[var(--admin-bg)] font-mono leading-relaxed">
|
||||
{`{
|
||||
"event": "order.status_changed",
|
||||
"timestamp": "2024-01-15T10:30:00Z",
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
uploadOlatheSweetLogoDark,
|
||||
type BrandSettings,
|
||||
} from "@/actions/brand-settings";
|
||||
import { AdminInput, AdminTextInput, AdminTextarea, AdminSelect } from "./design-system";
|
||||
|
||||
type Props = {
|
||||
settings: BrandSettings | null;
|
||||
@@ -202,21 +203,13 @@ export default function BrandSettingsForm({
|
||||
<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
|
||||
<AdminInput label="Brand" helpText={`Viewing ${activeBrandName}`}>
|
||||
<AdminSelect
|
||||
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>
|
||||
options={brands.map((b) => ({ value: b.id, label: b.name }))}
|
||||
/>
|
||||
</AdminInput>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
@@ -238,46 +231,37 @@ export default function BrandSettingsForm({
|
||||
</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"
|
||||
<AdminInput label="Legal Business Name">
|
||||
<AdminTextInput
|
||||
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
|
||||
</AdminInput>
|
||||
<AdminInput label="Phone">
|
||||
<AdminTextInput
|
||||
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
|
||||
</AdminInput>
|
||||
<AdminInput label="Email">
|
||||
<AdminTextInput
|
||||
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
|
||||
</AdminInput>
|
||||
<AdminInput label="Website">
|
||||
<AdminTextInput
|
||||
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>
|
||||
</AdminInput>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -291,60 +275,49 @@ export default function BrandSettingsForm({
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300">Street Address</label>
|
||||
<input
|
||||
type="text"
|
||||
<AdminInput label="Street Address">
|
||||
<AdminTextInput
|
||||
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>
|
||||
</AdminInput>
|
||||
<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"
|
||||
/>
|
||||
<AdminInput label="City">
|
||||
<AdminTextInput
|
||||
value={city}
|
||||
onChange={(e) => setCity(e.target.value)}
|
||||
placeholder="Stuart"
|
||||
/>
|
||||
</AdminInput>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300">State</label>
|
||||
<input
|
||||
type="text"
|
||||
<AdminInput label="State">
|
||||
<AdminTextInput
|
||||
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"
|
||||
</AdminInput>
|
||||
<AdminInput label="ZIP / Postal Code">
|
||||
<AdminTextInput
|
||||
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>
|
||||
</AdminInput>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300">Country</label>
|
||||
<select
|
||||
<AdminInput label="Country">
|
||||
<AdminSelect
|
||||
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>
|
||||
options={[
|
||||
{ value: "US", label: "United States" },
|
||||
{ value: "CA", label: "Canada" },
|
||||
]}
|
||||
/>
|
||||
</AdminInput>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -418,37 +391,29 @@ export default function BrandSettingsForm({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300 mb-1">
|
||||
Default Email Signature
|
||||
</label>
|
||||
<textarea
|
||||
<AdminInput
|
||||
label="Default Email Signature"
|
||||
helpText="Appears at the bottom of all outbound emails. Supports line breaks."
|
||||
>
|
||||
<AdminTextarea
|
||||
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"
|
||||
placeholder={"The Tuxedo Corn Team\norders@tuxedocorn.com | (772) 555-0100\nFresh from Florida since 1987"}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-zinc-500">
|
||||
Appears at the bottom of all outbound emails. Supports line breaks.
|
||||
</p>
|
||||
</div>
|
||||
</AdminInput>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300 mb-1">
|
||||
Invoice Footer Notes
|
||||
</label>
|
||||
<textarea
|
||||
<AdminInput
|
||||
label="Invoice Footer Notes"
|
||||
helpText="Printed at the bottom of invoices and order confirmations."
|
||||
>
|
||||
<AdminTextarea
|
||||
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"
|
||||
placeholder={"Thank you for your order! Pickup available at our farm stand.\nQuestions? Call (772) 555-0100 or email orders@tuxedocorn.com"}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-zinc-500">
|
||||
Printed at the bottom of invoices and order confirmations.
|
||||
</p>
|
||||
</div>
|
||||
</AdminInput>
|
||||
</div>
|
||||
|
||||
{/* Storefront Customization */}
|
||||
@@ -460,71 +425,55 @@ export default function BrandSettingsForm({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300 mb-1">Hero Tagline</label>
|
||||
<input
|
||||
type="text"
|
||||
<AdminInput label="Hero Tagline" helpText="Subtitle below the brand name in the hero section.">
|
||||
<AdminTextInput
|
||||
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>
|
||||
</AdminInput>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300 mb-1">Hero Image URL</label>
|
||||
<input
|
||||
<AdminInput
|
||||
label="Hero Image URL"
|
||||
helpText="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."
|
||||
>
|
||||
<AdminTextInput
|
||||
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>
|
||||
</AdminInput>
|
||||
{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>
|
||||
<label className="block text-sm font-medium text-zinc-300 mb-1">About Page Headline</label>
|
||||
<input
|
||||
type="text"
|
||||
<AdminInput label="About Page Headline">
|
||||
<AdminTextInput
|
||||
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>
|
||||
</AdminInput>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300 mb-1">About Page Subheadline</label>
|
||||
<input
|
||||
type="text"
|
||||
<AdminInput label="About Page Subheadline">
|
||||
<AdminTextInput
|
||||
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>
|
||||
</AdminInput>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300 mb-1">Custom Footer Text</label>
|
||||
<textarea
|
||||
<AdminInput label="Custom Footer Text" helpText="Appears above the copyright line in the brand footer.">
|
||||
<AdminTextarea
|
||||
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>
|
||||
</AdminInput>
|
||||
</div>
|
||||
|
||||
{/* Brand Colors */}
|
||||
@@ -711,17 +660,14 @@ export default function BrandSettingsForm({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300 mb-1">Schedule PDF Footer Notes</label>
|
||||
<textarea
|
||||
<AdminInput label="Schedule PDF Footer Notes" helpText="Printed at the bottom of the generated schedule PDF.">
|
||||
<AdminTextarea
|
||||
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>
|
||||
</AdminInput>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
@@ -838,27 +784,26 @@ function NexusStateInput({
|
||||
))}
|
||||
</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
|
||||
<AdminInput label="" className="flex-1">
|
||||
<AdminTextInput
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Type state code (e.g. CO) and press Enter"
|
||||
maxLength={2}
|
||||
/>
|
||||
</AdminInput>
|
||||
<AdminInput label="" className="w-32">
|
||||
<AdminSelect
|
||||
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>
|
||||
options={[
|
||||
{ value: "", label: "Add state" },
|
||||
...US_STATES.filter((s) => !value.includes(s)).map((s) => ({ value: s, label: s })),
|
||||
]}
|
||||
/>
|
||||
</AdminInput>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ 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 { PageHeader, AdminFilterTabs } from "@/components/admin/design-system";
|
||||
import type { Campaign } from "@/actions/communications/campaigns";
|
||||
import type { Template } from "@/actions/communications/templates";
|
||||
import type { Contact } from "@/actions/communications/contacts";
|
||||
@@ -17,65 +18,58 @@ import type { CampaignAnalytics } from "@/actions/harvest-reach/campaigns";
|
||||
|
||||
type Tab = "campaigns" | "templates" | "contacts" | "segments" | "logs" | "analytics";
|
||||
|
||||
const TABS: { id: Tab; label: string; icon: string }[] = [
|
||||
{ id: "campaigns", label: "Campaigns", icon: "mail" },
|
||||
{ id: "templates", label: "Templates", icon: "file-text" },
|
||||
{ id: "contacts", label: "Contacts", icon: "users" },
|
||||
{ id: "segments", label: "Segments", icon: "layers" },
|
||||
{ id: "logs", label: "Logs", icon: "list" },
|
||||
{ id: "analytics", label: "Analytics", icon: "chart" },
|
||||
];
|
||||
const MailIcon = () => (
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="2" y="4" width="20" height="16" rx="2"/>
|
||||
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
// Icon components
|
||||
const Icons = {
|
||||
mail: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="2" y="4" width="20" height="16" rx="2"/>
|
||||
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>
|
||||
</svg>
|
||||
),
|
||||
fileText: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||
<polyline points="14 2 14 8 20 8"/>
|
||||
<line x1="16" y1="13" x2="8" y2="13"/>
|
||||
<line x1="16" y1="17" x2="8" y2="17"/>
|
||||
<polyline points="10 9 9 9 8 9"/>
|
||||
</svg>
|
||||
),
|
||||
users: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
|
||||
<circle cx="9" cy="7" r="4"/>
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
|
||||
</svg>
|
||||
),
|
||||
layers: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polygon points="12 2 2 7 12 12 22 7 12 2"/>
|
||||
<polyline points="2 17 12 22 22 17"/>
|
||||
<polyline points="2 12 12 17 22 12"/>
|
||||
</svg>
|
||||
),
|
||||
list: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="8" y1="6" x2="21" y2="6"/>
|
||||
<line x1="8" y1="12" x2="21" y2="12"/>
|
||||
<line x1="8" y1="18" x2="21" y2="18"/>
|
||||
<line x1="3" y1="6" x2="3.01" y2="6"/>
|
||||
<line x1="3" y1="12" x2="3.01" y2="12"/>
|
||||
<line x1="3" y1="18" x2="3.01" y2="18"/>
|
||||
</svg>
|
||||
),
|
||||
chart: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="18" y1="20" x2="18" y2="10"/>
|
||||
<line x1="12" y1="20" x2="12" y2="4"/>
|
||||
<line x1="6" y1="20" x2="6" y2="14"/>
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
const FileTextIcon = () => (
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||
<polyline points="14 2 14 8 20 8"/>
|
||||
<line x1="16" y1="13" x2="8" y2="13"/>
|
||||
<line x1="16" y1="17" x2="8" y2="17"/>
|
||||
<polyline points="10 9 9 9 8 9"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const UsersIcon = () => (
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
|
||||
<circle cx="9" cy="7" r="4"/>
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const LayersIcon = () => (
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polygon points="12 2 2 7 12 12 22 7 12 2"/>
|
||||
<polyline points="2 17 12 22 22 17"/>
|
||||
<polyline points="2 12 12 17 22 12"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const ListIcon = () => (
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="8" y1="6" x2="21" y2="6"/>
|
||||
<line x1="8" y1="12" x2="21" y2="12"/>
|
||||
<line x1="8" y1="18" x2="21" y2="18"/>
|
||||
<line x1="3" y1="6" x2="3.01" y2="6"/>
|
||||
<line x1="3" y1="12" x2="3.01" y2="12"/>
|
||||
<line x1="3" y1="18" x2="3.01" y2="18"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const ChartIcon = () => (
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="18" y1="20" x2="18" y2="10"/>
|
||||
<line x1="12" y1="20" x2="12" y2="4"/>
|
||||
<line x1="6" y1="20" x2="6" y2="14"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default function CommunicationsPage({
|
||||
campaigns,
|
||||
@@ -107,48 +101,31 @@ export default function CommunicationsPage({
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--admin-bg)]">
|
||||
{/* Header */}
|
||||
{/* Page Header */}
|
||||
<div className="px-4 sm:px-6 md:px-8 pt-4 sm:pt-6">
|
||||
{/* Title row */}
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="flex h-10 w-10 sm:h-12 sm:w-12 items-center justify-center rounded-xl bg-emerald-600">
|
||||
{Icons.mail("h-5 w-5 sm:h-6 sm:w-6 text-white")}
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl sm:text-2xl font-black text-[var(--admin-text-primary)] tracking-tight">Harvest Reach</h1>
|
||||
<p className="text-xs text-[var(--admin-text-muted)]">Email campaigns, templates, and contacts</p>
|
||||
</div>
|
||||
</div>
|
||||
<PageHeader
|
||||
title="Harvest Reach"
|
||||
subtitle="Email campaigns, templates, and contacts"
|
||||
icon={<MailIcon />}
|
||||
/>
|
||||
|
||||
{/* Tab navigation */}
|
||||
<nav className="grid grid-cols-3 sm:grid-cols-6 gap-1 p-1.5 rounded-xl bg-white border border-stone-200">
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => {
|
||||
setCurrentTab(tab.id);
|
||||
setIsComposing(false);
|
||||
}}
|
||||
className={`relative flex flex-col sm:flex-row items-center justify-center gap-1 sm:gap-2 rounded-lg px-2 sm:px-4 py-2.5 sm:py-3 text-[10px] sm:text-sm font-semibold transition-colors ${
|
||||
currentTab === tab.id
|
||||
? "bg-emerald-600 text-white"
|
||||
: "text-stone-500 hover:text-stone-700 hover:bg-stone-50"
|
||||
}`}
|
||||
>
|
||||
{tab.icon === "mail" && Icons.mail("h-4 w-4")}
|
||||
{tab.icon === "file-text" && Icons.fileText("h-4 w-4")}
|
||||
{tab.icon === "users" && Icons.users("h-4 w-4")}
|
||||
{tab.icon === "layers" && Icons.layers("h-4 w-4")}
|
||||
{tab.icon === "list" && Icons.list("h-4 w-4")}
|
||||
{tab.icon === "chart" && Icons.chart("h-4 w-4")}
|
||||
<span className="hidden sm:inline">{tab.label}</span>
|
||||
<span className="sm:hidden">{tab.label.substring(0, 3)}</span>
|
||||
{currentTab === tab.id && (
|
||||
<div className="absolute bottom-1 sm:bottom-1.5 left-1/2 -translate-x-1/2 h-0.5 w-6 sm:w-12 bg-white rounded-full" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
<AdminFilterTabs
|
||||
activeTab={currentTab}
|
||||
onTabChange={(tab) => {
|
||||
setCurrentTab(tab as Tab);
|
||||
setIsComposing(false);
|
||||
}}
|
||||
tabs={[
|
||||
{ value: "campaigns", label: "Campaigns", icon: <MailIcon /> },
|
||||
{ value: "templates", label: "Templates", icon: <FileTextIcon /> },
|
||||
{ value: "contacts", label: "Contacts", icon: <UsersIcon /> },
|
||||
{ value: "segments", label: "Segments", icon: <LayersIcon /> },
|
||||
{ value: "logs", label: "Logs", icon: <ListIcon /> },
|
||||
{ value: "analytics", label: "Analytics", icon: <ChartIcon /> },
|
||||
]}
|
||||
size="md"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import Link from "next/link";
|
||||
import UpgradePlanModal from "@/components/admin/UpgradePlanModal";
|
||||
import { PageHeader, AdminButton, AdminFilterTabs, AdminBadge } from "@/components/admin/design-system";
|
||||
|
||||
type Section = {
|
||||
title: string;
|
||||
@@ -200,48 +201,50 @@ export default function DashboardClient({
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--admin-bg)]">
|
||||
{/* Header */}
|
||||
{/* Page Header */}
|
||||
<div className="px-4 sm:px-6 md:px-8 py-6 sm:py-8">
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 mb-4 sm:mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 sm:h-12 sm:w-12 items-center justify-center rounded-xl bg-emerald-600">
|
||||
<svg className="h-5 w-5 sm:h-6 sm:w-6 text-white" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="3" width="7" height="7" rx="1"/>
|
||||
<rect x="14" y="3" width="7" height="7" rx="1"/>
|
||||
<rect x="3" y="14" width="7" height="7" rx="1"/>
|
||||
<rect x="14" y="14" width="7" height="7" rx="1"/>
|
||||
</svg>
|
||||
<PageHeader
|
||||
icon={
|
||||
<svg className="h-6 w-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="3" width="7" height="7" rx="1"/>
|
||||
<rect x="14" y="3" width="7" height="7" rx="1"/>
|
||||
<rect x="3" y="14" width="7" height="7" rx="1"/>
|
||||
<rect x="14" y="14" width="7" height="7" rx="1"/>
|
||||
</svg>
|
||||
}
|
||||
title="Admin Dashboard"
|
||||
subtitle={`${brandName} Control Center`}
|
||||
actions={
|
||||
<div className="flex items-center gap-4">
|
||||
<a href="/admin/settings/billing" className="text-sm font-medium text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)] transition-colors">
|
||||
Billing →
|
||||
</a>
|
||||
{planTier === "starter" && brandId && (
|
||||
<AdminButton
|
||||
onClick={() => setIsUpgradeOpen(true)}
|
||||
size="md"
|
||||
>
|
||||
Upgrade Plan
|
||||
</AdminButton>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl sm:text-3xl font-black text-[var(--admin-text-primary)] tracking-tight">Admin Dashboard</h1>
|
||||
<p className="text-xs sm:text-sm text-[var(--admin-text-muted)]">{brandName} Control Center</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<a href="/admin/settings/billing" className="text-sm font-medium text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)] transition-colors">
|
||||
Billing →
|
||||
</a>
|
||||
{planTier === "starter" && brandId && (
|
||||
<button
|
||||
onClick={() => setIsUpgradeOpen(true)}
|
||||
className="rounded-full bg-emerald-600 hover:bg-emerald-500 px-5 py-2.5 text-sm font-semibold text-white transition-all shadow-sm"
|
||||
>
|
||||
Upgrade Plan
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
className="mb-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-4 sm:px-6 md:px-8 py-4 sm:py-6 -mt-4">
|
||||
{/* Usage stats - compact bar */}
|
||||
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-4 mb-4">
|
||||
<div className="flex items-center gap-4 mb-3">
|
||||
<span className={`rounded-full px-3 py-1 text-xs font-semibold tracking-wide ${
|
||||
planTier === "enterprise" ? "bg-amber-100 text-amber-700 border border-amber-200" :
|
||||
planTier === "farm" ? "bg-emerald-100 text-emerald-700 border border-emerald-200" :
|
||||
"bg-stone-100 text-stone-600 border border-stone-200"
|
||||
}`}>
|
||||
<AdminBadge variant={
|
||||
planTier === "enterprise" ? "warning" :
|
||||
planTier === "farm" ? "success" :
|
||||
"default"
|
||||
}>
|
||||
{planTier.charAt(0).toUpperCase() + planTier.slice(1)} Plan
|
||||
</span>
|
||||
</AdminBadge>
|
||||
<span className="text-xs text-stone-500">{brandId ? "Tuxedo Corn" : "All Brands"}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
@@ -255,10 +258,10 @@ export default function DashboardClient({
|
||||
<span className="text-xs font-medium text-stone-600">{label}</span>
|
||||
<span className="text-xs font-semibold text-stone-900">{value}</span>
|
||||
</div>
|
||||
<div className="h-1 rounded-full bg-stone-200 overflow-hidden">
|
||||
<div className="h-2 rounded-full bg-stone-100 overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-500 ${
|
||||
pct > 85 ? "bg-amber-500" : "bg-emerald-500"
|
||||
pct > 90 ? "bg-red-500" : pct > 75 ? "bg-amber-500" : "bg-[var(--admin-accent)]"
|
||||
}`}
|
||||
style={{ width: `${Math.min(pct, 100)}%` }}
|
||||
/>
|
||||
@@ -268,30 +271,21 @@ export default function DashboardClient({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab navigation */}
|
||||
<nav className="grid grid-cols-4 gap-1 p-1.5 rounded-xl bg-white border border-[var(--admin-border)]">
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`relative flex flex-col sm:flex-row items-center justify-center gap-1 sm:gap-2 rounded-lg px-2 sm:px-4 py-2.5 sm:py-3 text-[10px] sm:text-sm font-semibold transition-colors ${
|
||||
activeTab === tab.id
|
||||
? "bg-emerald-600 text-white"
|
||||
: "text-stone-500 hover:text-stone-700 hover:bg-stone-50"
|
||||
}`}
|
||||
>
|
||||
{tab.icon}
|
||||
<span>{tab.label}</span>
|
||||
{activeTab === tab.id && (
|
||||
<div className="absolute bottom-1 sm:bottom-1.5 left-1/2 -translate-x-1/2 h-0.5 w-6 sm:w-12 bg-white rounded-full" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
{/* Tab navigation using AdminFilterTabs */}
|
||||
<AdminFilterTabs
|
||||
activeTab={activeTab}
|
||||
onTabChange={(value) => setActiveTab(value as Tab)}
|
||||
tabs={TABS.map((tab) => ({
|
||||
value: tab.id,
|
||||
label: tab.label,
|
||||
icon: tab.icon,
|
||||
}))}
|
||||
size="md"
|
||||
showCounts={false}
|
||||
className="mb-4"
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-4 sm:px-6 md:px-8 py-4 sm:py-6">
|
||||
{/* Section Cards Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{tabSections.map((section) => {
|
||||
if (section.title === "Water Log" && !isWaterLogVisible) return null;
|
||||
@@ -310,7 +304,7 @@ export default function DashboardClient({
|
||||
isAddon && !isEnabled
|
||||
? "border-stone-200 shadow-sm opacity-75 hover:opacity-100"
|
||||
: isProminent
|
||||
? "border-emerald-200 shadow-md shadow-emerald-100/50"
|
||||
? "border-[var(--admin-accent)]/20 shadow-[0_2px_8px_rgba(0,0,0,0.04)]"
|
||||
: "border-stone-200 shadow-sm",
|
||||
].join(" ")}
|
||||
>
|
||||
@@ -319,27 +313,27 @@ export default function DashboardClient({
|
||||
isAddon && !isEnabled
|
||||
? "bg-stone-100 text-stone-400"
|
||||
: isProminent
|
||||
? "bg-emerald-100 text-emerald-600"
|
||||
: "bg-violet-100 text-violet-600"
|
||||
? "bg-emerald-50 text-emerald-600"
|
||||
: "bg-stone-100 text-stone-600"
|
||||
}`}>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
{isAddon && !isEnabled && (
|
||||
<span className="text-[10px] font-semibold text-amber-800 bg-amber-100 border border-amber-200 rounded-full px-2.5 py-0.5">
|
||||
<AdminBadge variant="warning">
|
||||
Add-on
|
||||
</span>
|
||||
</AdminBadge>
|
||||
)}
|
||||
{isAddon && isEnabled && (
|
||||
<span className="text-[10px] font-semibold text-emerald-800 bg-emerald-100 border border-emerald-200 rounded-full px-2.5 py-0.5">
|
||||
<AdminBadge variant="success">
|
||||
Active
|
||||
</span>
|
||||
</AdminBadge>
|
||||
)}
|
||||
{isProminent && (
|
||||
<span className="text-[10px] font-semibold text-emerald-800 bg-emerald-100 border border-emerald-200 rounded-full px-2.5 py-0.5">
|
||||
<AdminBadge variant="success">
|
||||
Core
|
||||
</span>
|
||||
</AdminBadge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { markPickupComplete } from "@/actions/pickup";
|
||||
import AdminBadge from "./design-system/AdminBadge";
|
||||
|
||||
type OrderItem = {
|
||||
id: string;
|
||||
@@ -213,9 +214,14 @@ export default function DriverPickupPanel({
|
||||
{hasAnyPickedUp && (
|
||||
<button
|
||||
onClick={() => setShowPickedUp((v) => !v)}
|
||||
className="w-full rounded-xl border border-emerald-200 bg-emerald-50 px-5 py-3 text-sm font-medium text-emerald-700 hover:bg-emerald-100 transition-colors"
|
||||
className="w-full rounded-xl border border-emerald-200 bg-emerald-50 px-5 py-3 text-sm font-medium text-emerald-700 hover:bg-emerald-100 transition-colors flex items-center gap-2"
|
||||
>
|
||||
{showPickedUp ? "▼ Hide" : "▶ Show"} {pickedUpOrders.length} picked up
|
||||
{showPickedUp ? (
|
||||
<><svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M5 15l7-7 7 7"/></svg> Hide</>
|
||||
) : (
|
||||
<><svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7"/></svg> Show</>
|
||||
)}
|
||||
{pickedUpOrders.length} picked up
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -275,13 +281,9 @@ function OrderCard({ order, canManagePickup, onMarkPickup, pickingUp, shortId }:
|
||||
<p className="mt-1 font-mono text-xs text-stone-400 uppercase">{shortId}</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<span className="rounded-full bg-amber-100 px-3 py-1 text-xs font-bold text-amber-700">
|
||||
Pending
|
||||
</span>
|
||||
<AdminBadge variant="warning">Pending</AdminBadge>
|
||||
{isMixed && (
|
||||
<span className="rounded-full bg-violet-100 px-2 py-0.5 text-xs font-semibold text-violet-700">
|
||||
Mixed
|
||||
</span>
|
||||
<AdminBadge variant="info">Mixed</AdminBadge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -417,9 +419,7 @@ function PickedUpCard({ order, shortId }: PickedUpCardProps) {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<span className="rounded-full bg-emerald-100 px-3 py-1 text-xs font-bold text-emerald-700">
|
||||
Picked Up
|
||||
</span>
|
||||
<AdminBadge variant="success">Picked Up</AdminBadge>
|
||||
{order.pickup_completed_at && (
|
||||
<p className="text-xs text-stone-400">{formatPickupTime(order.pickup_completed_at)}</p>
|
||||
)}
|
||||
|
||||
@@ -11,7 +11,7 @@ type Props = {
|
||||
maxWidth?: string;
|
||||
};
|
||||
|
||||
export default function GlassModal({ title, titleIcon, subtitle, onClose, children, maxWidth = "max-w-md" }: Props) {
|
||||
export default function GlassModal({ title, titleIcon, subtitle, onClose, children, maxWidth = "max-w-lg" }: Props) {
|
||||
// Lock body scroll
|
||||
useEffect(() => {
|
||||
document.body.style.overflow = "hidden";
|
||||
@@ -39,24 +39,14 @@ export default function GlassModal({ title, titleIcon, subtitle, onClose, childr
|
||||
>
|
||||
{/* Modal card - solid white with shadow for high contrast */}
|
||||
<div
|
||||
className={`relative w-full ${maxWidth} rounded-2xl shadow-2xl`}
|
||||
style={{
|
||||
backgroundColor: "#ffffff",
|
||||
border: "1px solid var(--admin-border)",
|
||||
boxShadow: "0 25px 50px -12px rgba(60, 56, 37, 0.35), 0 12px 24px -8px rgba(60, 56, 37, 0.2)",
|
||||
}}
|
||||
className={`relative w-full ${maxWidth} rounded-2xl bg-white shadow-[0_25px_50px_-12px_rgba(0,0,0,0.15)]`}
|
||||
>
|
||||
{/* Subtle top border accent */}
|
||||
<div
|
||||
className="absolute top-0 left-0 right-0 h-1 rounded-t-2xl overflow-hidden"
|
||||
style={{
|
||||
background: "linear-gradient(90deg, var(--admin-accent) 0%, var(--admin-accent-hover) 100%)",
|
||||
}}
|
||||
/>
|
||||
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-[var(--admin-accent)] to-[var(--admin-accent-hover)] rounded-t-2xl" />
|
||||
|
||||
{/* Header */}
|
||||
<div
|
||||
className="flex items-center justify-between px-6 py-5"
|
||||
className="flex items-center justify-between px-8 py-6"
|
||||
style={{ borderBottom: "1px solid var(--admin-border-light)" }}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -82,19 +72,8 @@ export default function GlassModal({ title, titleIcon, subtitle, onClose, childr
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-full transition-all"
|
||||
style={{
|
||||
backgroundColor: "rgba(60, 56, 37, 0.04)",
|
||||
color: "rgba(60, 56, 37, 0.5)",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = "rgba(60, 56, 37, 0.08)";
|
||||
e.currentTarget.style.color = "rgba(60, 56, 37, 0.7)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = "rgba(60, 56, 37, 0.04)";
|
||||
e.currentTarget.style.color = "rgba(60, 56, 37, 0.5)";
|
||||
}}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-full transition-all duration-150"
|
||||
style={{ backgroundColor: "var(--admin-bg-subtle)", color: "var(--admin-text-muted)" }}
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
@@ -103,7 +82,7 @@ export default function GlassModal({ title, titleIcon, subtitle, onClose, childr
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="relative p-6">
|
||||
<div className="relative px-8 py-8">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ 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";
|
||||
import { AdminButton } from "@/components/admin/design-system";
|
||||
|
||||
type Props = {
|
||||
brandId: string;
|
||||
@@ -124,12 +125,9 @@ export default function CampaignComposerPage({ brandId, campaigns, templates, se
|
||||
? "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"
|
||||
>
|
||||
<AdminButton onClick={() => window.location.href = "/admin/communications"}>
|
||||
Back to Campaigns
|
||||
</button>
|
||||
</AdminButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -145,13 +143,13 @@ export default function CampaignComposerPage({ brandId, campaigns, templates, se
|
||||
<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"
|
||||
? "bg-[var(--admin-text-primary)] text-white"
|
||||
: done
|
||||
? "bg-stone-200 text-stone-700"
|
||||
? "bg-[var(--admin-border)] text-[var(--admin-text-secondary)]"
|
||||
: "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"
|
||||
step === n ? "border-white" : done ? "border-[var(--admin-text-muted)]" : "border-zinc-600"
|
||||
}`}>
|
||||
{done ? (
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -162,7 +160,7 @@ export default function CampaignComposerPage({ brandId, campaigns, templates, se
|
||||
<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 className={`h-0.5 w-6 ${done ? "bg-[var(--admin-border)]" : "bg-slate-200"} mx-1`} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -189,23 +187,20 @@ export default function CampaignComposerPage({ brandId, campaigns, templates, se
|
||||
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"
|
||||
className="w-full border border-zinc-600 rounded-lg px-3 py-2.5 text-sm outline-none focus:border-[var(--admin-accent)]"
|
||||
/>
|
||||
<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"
|
||||
className="w-full border border-zinc-600 rounded-lg px-3 py-2.5 text-sm resize-none outline-none focus:border-[var(--admin-accent)]"
|
||||
/>
|
||||
</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"
|
||||
>
|
||||
<AdminButton onClick={() => setStep(2)}>
|
||||
Continue
|
||||
</button>
|
||||
</AdminButton>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -213,7 +208,7 @@ export default function CampaignComposerPage({ brandId, campaigns, templates, se
|
||||
{/* 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"
|
||||
className="rounded-xl border-2 border-dashed border-zinc-600 p-5 text-left hover:border-[var(--admin-border)] 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>
|
||||
@@ -227,8 +222,8 @@ export default function CampaignComposerPage({ brandId, campaigns, templates, se
|
||||
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"
|
||||
? "border-[var(--admin-text-primary)] bg-zinc-950"
|
||||
: "border-zinc-800 hover:border-[var(--admin-border)] hover:bg-zinc-800"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
@@ -263,7 +258,7 @@ export default function CampaignComposerPage({ brandId, campaigns, templates, se
|
||||
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"
|
||||
className="w-full border border-zinc-600 rounded-lg px-3 py-2.5 text-sm outline-none focus:border-[var(--admin-accent)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -275,7 +270,7 @@ export default function CampaignComposerPage({ brandId, campaigns, templates, se
|
||||
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"
|
||||
className="flex-1 border border-zinc-600 rounded-lg px-3 py-2.5 text-sm bg-zinc-900 outline-none focus:border-[var(--admin-accent)]"
|
||||
>
|
||||
<option value="">— Choose a saved segment —</option>
|
||||
{segments.map((s) => (
|
||||
@@ -283,12 +278,9 @@ export default function CampaignComposerPage({ brandId, campaigns, templates, se
|
||||
))}
|
||||
</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"
|
||||
>
|
||||
<AdminButton variant="secondary" size="sm" onClick={() => setSelectedSegmentId("")}>
|
||||
Clear
|
||||
</button>
|
||||
</AdminButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -297,20 +289,13 @@ export default function CampaignComposerPage({ brandId, campaigns, templates, se
|
||||
{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"
|
||||
>
|
||||
<AdminButton variant="ghost" onClick={() => setStep(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"
|
||||
>
|
||||
</AdminButton>
|
||||
<AdminButton onClick={() => setStep(3)} disabled={!name}>
|
||||
Continue to Preview
|
||||
</button>
|
||||
</AdminButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -326,7 +311,7 @@ export default function CampaignComposerPage({ brandId, campaigns, templates, se
|
||||
{/* 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 className="w-8 h-8 rounded-full bg-[var(--admin-border)]" />
|
||||
<div>
|
||||
<p className="text-xs font-medium text-zinc-300">Your Brand</p>
|
||||
<p className="text-xs text-slate-400">To: {"{{customer_name}}"}</p>
|
||||
@@ -345,24 +330,18 @@ export default function CampaignComposerPage({ brandId, campaigns, templates, se
|
||||
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"
|
||||
className="w-full border border-zinc-600 rounded-lg px-3 py-2.5 text-sm outline-none focus:border-[var(--admin-accent)]"
|
||||
/>
|
||||
</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"
|
||||
>
|
||||
<AdminButton variant="ghost" onClick={() => setStep(2)}>
|
||||
<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"
|
||||
>
|
||||
</AdminButton>
|
||||
<AdminButton onClick={() => setStep(4)}>
|
||||
Continue to Schedule
|
||||
</button>
|
||||
</AdminButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -385,12 +364,12 @@ export default function CampaignComposerPage({ brandId, campaigns, templates, se
|
||||
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"
|
||||
? "border-[var(--admin-text-primary)] bg-[var(--admin-text-primary)] text-white"
|
||||
: "border-zinc-800 hover:border-[var(--admin-border)] 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>
|
||||
<p className={`text-xs mt-0.5 ${campaignType === ct.value ? "text-[var(--admin-border)]" : "text-slate-400"}`}>{ct.desc}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -405,7 +384,7 @@ export default function CampaignComposerPage({ brandId, campaigns, templates, se
|
||||
type="radio"
|
||||
checked={sendNow}
|
||||
onChange={() => setSendNow(true)}
|
||||
className="accent-stone-900 w-4 h-4"
|
||||
className="accent-[var(--admin-accent)] w-4 h-4"
|
||||
/>
|
||||
<span className="text-sm font-medium text-zinc-300">Send now</span>
|
||||
</label>
|
||||
@@ -414,7 +393,7 @@ export default function CampaignComposerPage({ brandId, campaigns, templates, se
|
||||
type="radio"
|
||||
checked={!sendNow}
|
||||
onChange={() => setSendNow(false)}
|
||||
className="accent-stone-900 w-4 h-4"
|
||||
className="accent-[var(--admin-accent)] w-4 h-4"
|
||||
/>
|
||||
<span className="text-sm font-medium text-zinc-300">Schedule for later</span>
|
||||
</label>
|
||||
@@ -428,7 +407,7 @@ export default function CampaignComposerPage({ brandId, campaigns, templates, se
|
||||
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"
|
||||
className="border border-zinc-600 rounded-lg px-3 py-2.5 text-sm outline-none focus:border-[var(--admin-accent)]"
|
||||
min={new Date().toISOString().slice(0, 16)}
|
||||
/>
|
||||
</div>
|
||||
@@ -452,20 +431,17 @@ export default function CampaignComposerPage({ brandId, campaigns, templates, se
|
||||
{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"
|
||||
>
|
||||
<AdminButton variant="ghost" onClick={() => setStep(3)}>
|
||||
<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
|
||||
</AdminButton>
|
||||
<AdminButton
|
||||
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"
|
||||
isLoading={saving}
|
||||
disabled={(!sendNow && !scheduledAt)}
|
||||
>
|
||||
{saving ? "Processing…" : sendNow ? "Send Campaign" : "Schedule Campaign"}
|
||||
</button>
|
||||
</AdminButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { type SegmentRuleV2, type PreviewResult } from "@/actions/harvest-reach/segments";
|
||||
import { AdminSearchInput, AdminButton } from "@/components/admin/design-system";
|
||||
|
||||
// Icon components
|
||||
const Icons = {
|
||||
@@ -13,12 +14,6 @@ const Icons = {
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
|
||||
</svg>
|
||||
),
|
||||
search: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="11" cy="11" r="8"/>
|
||||
<path d="m21 21-4.3-4.3"/>
|
||||
</svg>
|
||||
),
|
||||
spinner: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="12" cy="12" r="10" strokeOpacity="0.25"/>
|
||||
@@ -82,7 +77,7 @@ export default function MatchingCustomersPanel({ brandId, rules }: Props) {
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">Matching Customers</h3>
|
||||
{preview && (
|
||||
<span className="inline-flex items-center rounded-full bg-emerald-100 px-2.5 py-0.5 text-xs font-semibold text-emerald-700">
|
||||
<span className="inline-flex items-center rounded-full bg-[var(--admin-accent-light)] px-2.5 py-0.5 text-xs font-semibold text-[var(--admin-accent)]">
|
||||
{total.toLocaleString()} total
|
||||
</span>
|
||||
)}
|
||||
@@ -106,18 +101,12 @@ export default function MatchingCustomersPanel({ brandId, rules }: Props) {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-3 flex items-center pointer-events-none">
|
||||
{Icons.search("h-4 w-4 text-[var(--admin-text-muted)]")}
|
||||
</div>
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search by name or email…"
|
||||
value={search}
|
||||
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
|
||||
className="w-full pl-10 pr-3 py-2 text-sm border border-[var(--admin-border)] rounded-lg bg-white text-[var(--admin-text-primary)] focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<AdminSearchInput
|
||||
placeholder="Search by name or email…"
|
||||
value={search}
|
||||
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
|
||||
onClear={() => setSearch("")}
|
||||
/>
|
||||
|
||||
{paged.length === 0 ? (
|
||||
<p className="text-sm text-[var(--admin-text-muted)] text-center py-6">No customers match these filters.</p>
|
||||
@@ -128,7 +117,7 @@ export default function MatchingCustomersPanel({ brandId, rules }: Props) {
|
||||
key={c.id}
|
||||
className="flex items-center gap-3 px-3 py-2.5 rounded-lg hover:bg-[var(--admin-card-hover)] transition-colors"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-full bg-emerald-100 flex items-center justify-center text-xs font-semibold text-emerald-700 flex-shrink-0">
|
||||
<div className="w-8 h-8 rounded-full bg-[var(--admin-accent-light)] flex items-center justify-center text-xs font-semibold text-[var(--admin-accent)] flex-shrink-0">
|
||||
{c.name ? c.name.slice(0, 2).toUpperCase() : "??"}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
@@ -158,22 +147,26 @@ export default function MatchingCustomersPanel({ brandId, rules }: Props) {
|
||||
Showing {page * PAGE_SIZE + 1}–{Math.min((page + 1) * PAGE_SIZE, searched.length)} of {searched.length}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
<AdminButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||
disabled={page === 0}
|
||||
className="inline-flex items-center gap-1 px-3 py-1 text-xs rounded-lg border border-[var(--admin-border)] text-[var(--admin-text-muted)] hover:bg-[var(--admin-card-hover)] disabled:opacity-50 transition-colors"
|
||||
icon={Icons.chevronLeft("h-4 w-4")}
|
||||
iconPosition="left"
|
||||
>
|
||||
{Icons.chevronLeft("h-4 w-4")}
|
||||
<span>Prev</span>
|
||||
</button>
|
||||
<button
|
||||
Prev
|
||||
</AdminButton>
|
||||
<AdminButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
disabled={(page + 1) * PAGE_SIZE >= searched.length}
|
||||
className="inline-flex items-center gap-1 px-3 py-1 text-xs rounded-lg border border-[var(--admin-border)] text-[var(--admin-text-muted)] hover:bg-[var(--admin-card-hover)] disabled:opacity-50 transition-colors"
|
||||
icon={Icons.chevronRight("h-4 w-4")}
|
||||
iconPosition="right"
|
||||
>
|
||||
<span>Next</span>
|
||||
{Icons.chevronRight("h-4 w-4")}
|
||||
</button>
|
||||
Next
|
||||
</AdminButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -6,6 +6,7 @@ import SegmentBuilderPanel from "./SegmentBuilderPanel";
|
||||
import MatchingCustomersPanel from "./MatchingCustomersPanel";
|
||||
import SegmentListSidebar from "./SegmentListSidebar";
|
||||
import SegmentEditModal from "./SegmentEditModal";
|
||||
import { PageHeader, AdminButton } from "@/components/admin/design-system";
|
||||
|
||||
type Props = {
|
||||
brandId: string;
|
||||
@@ -13,15 +14,13 @@ type Props = {
|
||||
};
|
||||
|
||||
// Icon components
|
||||
const Icons = {
|
||||
layers: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polygon points="12 2 2 7 12 12 22 7 12 2"/>
|
||||
<polyline points="2 17 12 22 22 17"/>
|
||||
<polyline points="2 12 12 17 22 12"/>
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
const LayersIcon = (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polygon points="12 2 2 7 12 12 22 7 12 2"/>
|
||||
<polyline points="2 17 12 22 22 17"/>
|
||||
<polyline points="2 12 12 17 22 12"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default function SegmentBuilderPage({ brandId, initialSegments }: Props) {
|
||||
const [segments, setSegments] = useState<Segment[]>(initialSegments);
|
||||
@@ -80,20 +79,11 @@ export default function SegmentBuilderPage({ brandId, initialSegments }: Props)
|
||||
|
||||
return (
|
||||
<div className="p-4 sm:p-6 space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-[var(--admin-text-primary)]">
|
||||
{Icons.layers("w-4 h-4 text-[var(--admin-bg)]")}
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base sm:text-lg font-bold text-[var(--admin-text-primary)]">Segment Builder</h2>
|
||||
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)]">
|
||||
Build filters to define your audience, then save and reuse the segment.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<PageHeader
|
||||
icon={<LayersIcon className="w-5 h-5" />}
|
||||
title="Segment Builder"
|
||||
subtitle="Build filters to define your audience, then save and reuse the segment."
|
||||
/>
|
||||
|
||||
{/* Main layout */}
|
||||
<div className="flex flex-col lg:flex-row gap-4">
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type SegmentFilterType,
|
||||
type SegmentFilterParams,
|
||||
} from "@/actions/harvest-reach/segments";
|
||||
import { AdminButton, AdminSearchInput } from "@/components/admin/design-system";
|
||||
|
||||
// Icon components
|
||||
const Icons = {
|
||||
@@ -85,7 +86,7 @@ export default function SegmentBuilderPanel({ brandId, rules, onChange, onSave,
|
||||
onClick={() => setCombinator(c)}
|
||||
className={`px-3 py-1 text-xs font-semibold rounded-md transition-colors ${
|
||||
rules.combinator === c
|
||||
? "bg-emerald-600 text-white"
|
||||
? "bg-[var(--admin-accent)] text-white"
|
||||
: "text-[var(--admin-text-muted)] hover:bg-[var(--admin-card-hover)]"
|
||||
}`}
|
||||
>
|
||||
@@ -123,7 +124,7 @@ export default function SegmentBuilderPanel({ brandId, rules, onChange, onSave,
|
||||
<button
|
||||
key={ft.value}
|
||||
onClick={() => addFilter(ft.value)}
|
||||
className="px-3 py-1.5 text-xs font-medium rounded-full border border-[var(--admin-border)] text-[var(--admin-text-muted)] hover:bg-emerald-50 hover:border-emerald-200 hover:text-emerald-700 transition-colors"
|
||||
className="px-3 py-1.5 text-xs font-medium rounded-full border border-[var(--admin-border)] text-[var(--admin-text-muted)] hover:bg-[var(--admin-accent-light)] hover:border-[var(--admin-accent)] hover:text-[var(--admin-accent)] transition-colors"
|
||||
>
|
||||
+ {ft.label}
|
||||
</button>
|
||||
@@ -132,13 +133,13 @@ export default function SegmentBuilderPanel({ brandId, rules, onChange, onSave,
|
||||
|
||||
{/* Save button */}
|
||||
<div className="pt-1 border-t border-[var(--admin-border)]">
|
||||
<button
|
||||
<AdminButton
|
||||
onClick={onSave}
|
||||
disabled={rules.filters.length === 0}
|
||||
className="w-full py-2.5 rounded-lg bg-emerald-600 text-white text-sm font-semibold hover:bg-emerald-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
fullWidth
|
||||
>
|
||||
{hasActiveSegment ? "Update Segment" : "Save Segment"}
|
||||
</button>
|
||||
</AdminButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -187,7 +188,7 @@ function FilterBlock({ brandId, filter, onChange, onRemove }: FilterBlockProps)
|
||||
onChange={(e) =>
|
||||
onChange({ type: e.target.value as SegmentFilterType, params: emptyFilter(e.target.value as SegmentFilterType).params })
|
||||
}
|
||||
className="text-sm font-medium border border-[var(--admin-border)] rounded-lg px-2.5 py-1.5 bg-white text-[var(--admin-text-primary)] outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
|
||||
className="text-sm font-medium border border-[var(--admin-border)] rounded-lg px-2.5 py-1.5 bg-white text-[var(--admin-text-primary)] outline-none focus:ring-2 focus:ring-[var(--admin-accent)] focus:border-[var(--admin-accent)]"
|
||||
>
|
||||
{FILTER_TYPES.map((ft) => (
|
||||
<option key={ft.value} value={ft.value}>{ft.label}</option>
|
||||
@@ -210,7 +211,7 @@ function FilterBlock({ brandId, filter, onChange, onRemove }: FilterBlockProps)
|
||||
value={filter.params.stop_id ?? ""}
|
||||
onChange={(e) => onChange({ params: { ...filter.params, stop_id: e.target.value } })}
|
||||
onMouseEnter={() => loadStops(filter.type === "stop")}
|
||||
className="border border-[var(--admin-border)] rounded-lg px-3 py-2 text-sm bg-white w-full outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
|
||||
className="border border-[var(--admin-border)] rounded-lg px-3 py-2 text-sm bg-white w-full outline-none focus:ring-2 focus:ring-[var(--admin-accent)] focus:border-[var(--admin-accent)]"
|
||||
>
|
||||
<option value="">Select {filter.type === "stop" ? "past" : "upcoming"} stop…</option>
|
||||
{stops.map((s) => (
|
||||
@@ -222,13 +223,13 @@ function FilterBlock({ brandId, filter, onChange, onRemove }: FilterBlockProps)
|
||||
type="date"
|
||||
value={filter.params.date_from ?? ""}
|
||||
onChange={(e) => onChange({ params: { ...filter.params, date_from: e.target.value } })}
|
||||
className="border border-[var(--admin-border)] rounded-lg px-3 py-2 text-sm bg-white outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
|
||||
className="border border-[var(--admin-border)] rounded-lg px-3 py-2 text-sm bg-white outline-none focus:ring-2 focus:ring-[var(--admin-accent)] focus:border-[var(--admin-accent)]"
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
value={filter.params.date_to ?? ""}
|
||||
onChange={(e) => onChange({ params: { ...filter.params, date_to: e.target.value } })}
|
||||
className="border border-[var(--admin-border)] rounded-lg px-3 py-2 text-sm bg-white outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
|
||||
className="border border-[var(--admin-border)] rounded-lg px-3 py-2 text-sm bg-white outline-none focus:ring-2 focus:ring-[var(--admin-accent)] focus:border-[var(--admin-accent)]"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
@@ -244,7 +245,7 @@ function FilterBlock({ brandId, filter, onChange, onRemove }: FilterBlockProps)
|
||||
if (e.target.value) loadProducts();
|
||||
}}
|
||||
onMouseEnter={loadProducts}
|
||||
className="border border-[var(--admin-border)] rounded-lg px-3 py-2 text-sm bg-white w-full outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
|
||||
className="border border-[var(--admin-border)] rounded-lg px-3 py-2 text-sm bg-white w-full outline-none focus:ring-2 focus:ring-[var(--admin-accent)] focus:border-[var(--admin-accent)]"
|
||||
>
|
||||
<option value="">Select a product…</option>
|
||||
{products.map((p) => (
|
||||
@@ -259,7 +260,7 @@ function FilterBlock({ brandId, filter, onChange, onRemove }: FilterBlockProps)
|
||||
max={365}
|
||||
value={filter.params.days_back ?? 90}
|
||||
onChange={(e) => onChange({ params: { ...filter.params, days_back: parseInt(e.target.value) } })}
|
||||
className="border border-[var(--admin-border)] rounded-lg px-2.5 py-1.5 text-sm w-20 bg-white outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
|
||||
className="border border-[var(--admin-border)] rounded-lg px-2.5 py-1.5 text-sm w-20 bg-white outline-none focus:ring-2 focus:ring-[var(--admin-accent)] focus:border-[var(--admin-accent)]"
|
||||
/>
|
||||
<span className="text-xs text-[var(--admin-text-muted)]">days</span>
|
||||
</div>
|
||||
@@ -281,14 +282,14 @@ function FilterBlock({ brandId, filter, onChange, onRemove }: FilterBlockProps)
|
||||
},
|
||||
})
|
||||
}
|
||||
className="border border-[var(--admin-border)] rounded-lg px-3 py-2 text-sm bg-white outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
|
||||
className="border border-[var(--admin-border)] rounded-lg px-3 py-2 text-sm bg-white outline-none focus:ring-2 focus:ring-[var(--admin-accent)] focus:border-[var(--admin-accent)]"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="City (optional)"
|
||||
value={filter.params.city ?? ""}
|
||||
onChange={(e) => onChange({ params: { ...filter.params, city: e.target.value } })}
|
||||
className="border border-[var(--admin-border)] rounded-lg px-3 py-2 text-sm bg-white outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
|
||||
className="border border-[var(--admin-border)] rounded-lg px-3 py-2 text-sm bg-white outline-none focus:ring-2 focus:ring-[var(--admin-accent)] focus:border-[var(--admin-accent)]"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
@@ -301,7 +302,7 @@ function FilterBlock({ brandId, filter, onChange, onRemove }: FilterBlockProps)
|
||||
onChange={(e) =>
|
||||
onChange({ params: { ...filter.params, order_history: e.target.value as "all" | "first_order" | "repeat" } })
|
||||
}
|
||||
className="border border-[var(--admin-border)] rounded-lg px-3 py-2 text-sm bg-white outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
|
||||
className="border border-[var(--admin-border)] rounded-lg px-3 py-2 text-sm bg-white outline-none focus:ring-2 focus:ring-[var(--admin-accent)] focus:border-[var(--admin-accent)]"
|
||||
>
|
||||
{ORDER_HISTORY_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
@@ -315,7 +316,7 @@ function FilterBlock({ brandId, filter, onChange, onRemove }: FilterBlockProps)
|
||||
max={365}
|
||||
value={filter.params.days_back ?? 90}
|
||||
onChange={(e) => onChange({ params: { ...filter.params, days_back: parseInt(e.target.value) } })}
|
||||
className="border border-[var(--admin-border)] rounded-lg px-2.5 py-1.5 text-sm w-20 bg-white outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
|
||||
className="border border-[var(--admin-border)] rounded-lg px-2.5 py-1.5 text-sm w-20 bg-white outline-none focus:ring-2 focus:ring-[var(--admin-accent)] focus:border-[var(--admin-accent)]"
|
||||
/>
|
||||
<span className="text-xs text-[var(--admin-text-muted)]">days</span>
|
||||
</div>
|
||||
@@ -336,7 +337,7 @@ function FilterBlock({ brandId, filter, onChange, onRemove }: FilterBlockProps)
|
||||
},
|
||||
})
|
||||
}
|
||||
className="border border-[var(--admin-border)] rounded-lg px-3 py-2 text-sm bg-white outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
|
||||
className="border border-[var(--admin-border)] rounded-lg px-3 py-2 text-sm bg-white outline-none focus:ring-2 focus:ring-[var(--admin-accent)] focus:border-[var(--admin-accent)]"
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { AdminButton } from "@/components/admin/design-system";
|
||||
|
||||
// Icon components
|
||||
const Icons = {
|
||||
@@ -50,7 +51,7 @@ export default function SegmentEditModal({ initialName = "", initialDescription
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-[var(--admin-border)]">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-emerald-600">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-[var(--admin-accent)]">
|
||||
{Icons.layers("h-5 w-5 text-white")}
|
||||
</div>
|
||||
<div>
|
||||
@@ -80,7 +81,7 @@ export default function SegmentEditModal({ initialName = "", initialDescription
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSave()}
|
||||
placeholder="e.g. Fort Pierce Regulars"
|
||||
className="w-full border border-[var(--admin-border)] rounded-lg px-3 py-2.5 text-sm bg-white text-[var(--admin-text-primary)] focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 outline-none"
|
||||
className="w-full border border-[var(--admin-border)] rounded-lg px-3 py-2.5 text-sm bg-white text-[var(--admin-text-primary)] focus:ring-2 focus:ring-[var(--admin-accent)] focus:border-[var(--admin-accent)] outline-none"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
@@ -93,26 +94,28 @@ export default function SegmentEditModal({ initialName = "", initialDescription
|
||||
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-[var(--admin-border)] rounded-lg px-3 py-2.5 text-sm bg-white text-[var(--admin-text-primary)] resize-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 outline-none"
|
||||
className="w-full border border-[var(--admin-border)] rounded-lg px-3 py-2.5 text-sm bg-white text-[var(--admin-text-primary)] resize-none focus:ring-2 focus:ring-[var(--admin-accent)] focus:border-[var(--admin-accent)] outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex gap-3 px-6 py-4 border-t border-[var(--admin-border)]">
|
||||
<button
|
||||
<AdminButton
|
||||
variant="secondary"
|
||||
onClick={onClose}
|
||||
className="flex-1 py-2.5 rounded-lg border border-[var(--admin-border)] text-sm font-medium text-[var(--admin-text-muted)] hover:bg-[var(--admin-card-hover)] transition-colors"
|
||||
fullWidth
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
</AdminButton>
|
||||
<AdminButton
|
||||
onClick={handleSave}
|
||||
disabled={!name.trim() || saving}
|
||||
className="flex-1 py-2.5 rounded-lg bg-emerald-600 text-white text-sm font-semibold hover:bg-emerald-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
isLoading={saving}
|
||||
fullWidth
|
||||
>
|
||||
{saving ? "Saving…" : "Save Segment"}
|
||||
</button>
|
||||
</AdminButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import type { Segment } from "@/actions/harvest-reach/segments";
|
||||
import { AdminButton, AdminIconButton, AdminSearchInput } from "@/components/admin/design-system";
|
||||
|
||||
// Icon components
|
||||
const Icons = {
|
||||
@@ -18,12 +19,6 @@ const Icons = {
|
||||
<line x1="14" y1="11" x2="14" y2="17"/>
|
||||
</svg>
|
||||
),
|
||||
search: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="11" cy="11" r="8"/>
|
||||
<path d="m21 21-4.3-4.3"/>
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
type Props = {
|
||||
@@ -46,27 +41,21 @@ export default function SegmentListSidebar({ segments, activeSegmentId, onSelect
|
||||
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-4 flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">Saved Segments</h3>
|
||||
<button
|
||||
<AdminIconButton
|
||||
onClick={onNew}
|
||||
className="w-7 h-7 rounded-lg bg-emerald-600 text-white flex items-center justify-center hover:bg-emerald-700 transition-colors"
|
||||
aria-label="New segment"
|
||||
label="New segment"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
>
|
||||
{Icons.plus("w-4 h-4")}
|
||||
</button>
|
||||
<Icons.plus className="w-4 h-4" />
|
||||
</AdminIconButton>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-3 flex items-center pointer-events-none">
|
||||
{Icons.search("h-4 w-4 text-[var(--admin-text-muted)]")}
|
||||
</div>
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search segments…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full pl-10 pr-3 py-2 text-sm border border-[var(--admin-border)] rounded-lg bg-white text-[var(--admin-text-primary)] focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<AdminSearchInput
|
||||
placeholder="Search segments…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{filtered.length === 0 && (
|
||||
@@ -80,18 +69,22 @@ export default function SegmentListSidebar({ segments, activeSegmentId, onSelect
|
||||
<div className="rounded-xl border border-red-200 bg-red-50 p-3 flex flex-col gap-2">
|
||||
<p className="text-xs text-red-600 font-medium">Delete "{segment.name}"?</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
<AdminButton
|
||||
variant="danger"
|
||||
size="sm"
|
||||
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-semibold transition-colors"
|
||||
fullWidth
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
<button
|
||||
</AdminButton>
|
||||
<AdminButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setConfirmDelete(null)}
|
||||
className="flex-1 py-1.5 text-xs rounded-lg border border-[var(--admin-border)] bg-white text-[var(--admin-text-muted)] hover:bg-[var(--admin-card-hover)] font-medium transition-colors"
|
||||
fullWidth
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</AdminButton>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -99,7 +92,7 @@ export default function SegmentListSidebar({ segments, activeSegmentId, onSelect
|
||||
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-emerald-50 border border-emerald-200"
|
||||
? "bg-[var(--admin-accent-light)] border border-[var(--admin-accent)]"
|
||||
: "hover:bg-[var(--admin-card-hover)] border border-transparent"
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { updateWaterHeadgate, deleteWaterHeadgate } from "@/actions/water-log/admin";
|
||||
import { AdminInput, AdminTextInput, AdminSelect } from "./design-system";
|
||||
import { AdminButton } from "./design-system";
|
||||
|
||||
type Headgate = {
|
||||
id: string;
|
||||
@@ -56,64 +58,58 @@ export default function HeadgateEditForm({ headgate, backHref = "/admin/water-lo
|
||||
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">
|
||||
<div className="rounded-lg bg-red-100 border border-red-200 p-3 text-sm text-red-700">
|
||||
{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
|
||||
/>
|
||||
<AdminInput label="Name" required>
|
||||
<AdminTextInput
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Headgate name"
|
||||
/>
|
||||
</AdminInput>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-400 mb-1">Default Unit</label>
|
||||
<select
|
||||
<AdminInput label="Default Unit">
|
||||
<AdminSelect
|
||||
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>
|
||||
options={UNIT_OPTIONS.map((u) => ({ value: u, label: u }))}
|
||||
/>
|
||||
</AdminInput>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-400 mb-1">Status</label>
|
||||
<select
|
||||
<AdminInput label="Status">
|
||||
<AdminSelect
|
||||
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>
|
||||
options={[
|
||||
{ value: "1", label: "Active" },
|
||||
{ value: "0", label: "Inactive" },
|
||||
]}
|
||||
/>
|
||||
</AdminInput>
|
||||
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<button
|
||||
<AdminButton
|
||||
type="button"
|
||||
variant="danger"
|
||||
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"
|
||||
isLoading={deleting}
|
||||
>
|
||||
{deleting ? "..." : "Delete Headgate"}
|
||||
</button>
|
||||
<button
|
||||
Delete Headgate
|
||||
</AdminButton>
|
||||
<AdminButton
|
||||
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"
|
||||
isLoading={saving}
|
||||
>
|
||||
{saving ? "..." : "Save Changes"}
|
||||
</button>
|
||||
Save Changes
|
||||
</AdminButton>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { getMessageLogs, type MessageLogEntry } from "@/actions/communications/send";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import AdminBadge from "./design-system/AdminBadge";
|
||||
|
||||
// Icon components
|
||||
const Icons = {
|
||||
@@ -61,22 +62,30 @@ const Icons = {
|
||||
),
|
||||
};
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
queued: "bg-stone-100 text-stone-600",
|
||||
sent: "bg-blue-100 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-100 text-red-700",
|
||||
failed: "bg-red-100 text-red-700",
|
||||
unsubscribed: "bg-orange-100 text-orange-700",
|
||||
// Status to badge variant mapping
|
||||
const getStatusBadgeProps = (status: string): { variant: "default" | "success" | "warning" | "danger" | "info"; dot: boolean } => {
|
||||
const map: Record<string, { variant: "default" | "success" | "warning" | "danger" | "info"; dot: boolean }> = {
|
||||
queued: { variant: "default", dot: true },
|
||||
sent: { variant: "info", dot: true },
|
||||
delivered: { variant: "success", dot: true },
|
||||
opened: { variant: "info", dot: true },
|
||||
clicked: { variant: "warning", dot: true },
|
||||
bounced: { variant: "danger", dot: true },
|
||||
failed: { variant: "danger", dot: true },
|
||||
unsubscribed: { variant: "warning", dot: true },
|
||||
};
|
||||
return map[status] ?? { variant: "default", dot: true };
|
||||
};
|
||||
|
||||
const METHOD_COLORS: Record<string, string> = {
|
||||
email: "bg-blue-100 text-blue-700",
|
||||
sms: "bg-emerald-100 text-emerald-700",
|
||||
push: "bg-purple-100 text-purple-700",
|
||||
internal: "bg-stone-100 text-stone-600",
|
||||
// Method to badge variant mapping
|
||||
const getMethodBadgeProps = (method: string): { variant: "default" | "success" | "warning" | "danger" | "info"; dot: boolean } => {
|
||||
const map: Record<string, { variant: "default" | "success" | "warning" | "danger" | "info"; dot: boolean }> = {
|
||||
email: { variant: "info", dot: true },
|
||||
sms: { variant: "success", dot: true },
|
||||
push: { variant: "warning", dot: true },
|
||||
internal: { variant: "default", dot: true },
|
||||
};
|
||||
return map[method] ?? { variant: "default", dot: true };
|
||||
};
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
@@ -252,17 +261,19 @@ export default function MessageLogPanel({ brandId }: { brandId?: string }) {
|
||||
{log.customer_email ?? "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold ${METHOD_COLORS[log.delivery_method] ?? "bg-stone-100 text-stone-600"}`}>
|
||||
{log.delivery_method}
|
||||
</span>
|
||||
{(() => {
|
||||
const props = getMethodBadgeProps(log.delivery_method);
|
||||
return <AdminBadge variant={props.variant} dot>{log.delivery_method}</AdminBadge>;
|
||||
})()}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[var(--admin-text-primary)] text-xs truncate max-w-[160px]" title={log.subject ?? undefined}>
|
||||
{log.subject ?? "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold ${STATUS_COLORS[log.status] ?? "bg-stone-100 text-stone-600"}`}>
|
||||
{log.status}
|
||||
</span>
|
||||
{(() => {
|
||||
const props = getStatusBadgeProps(log.status);
|
||||
return <AdminBadge variant={props.variant} dot>{log.status}</AdminBadge>;
|
||||
})()}
|
||||
{log.error_message && (
|
||||
<p className="text-[10px] text-red-600 mt-0.5 truncate max-w-[100px]" title={log.error_message}>
|
||||
{log.error_message}
|
||||
@@ -272,24 +283,16 @@ export default function MessageLogPanel({ brandId }: { brandId?: string }) {
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{log.delivered_at && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-50 border border-emerald-200 text-emerald-700 px-1.5 py-0.5 text-[10px] font-medium">
|
||||
{Icons.check("h-3 w-3")} Delivered
|
||||
</span>
|
||||
<AdminBadge variant="success" dot>Delivered</AdminBadge>
|
||||
)}
|
||||
{log.opened_at && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-violet-50 border border-violet-200 text-violet-700 px-1.5 py-0.5 text-[10px] font-medium">
|
||||
{Icons.eye("h-3 w-3")} Opened
|
||||
</span>
|
||||
<AdminBadge variant="info" dot>Opened</AdminBadge>
|
||||
)}
|
||||
{log.clicked_at && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-amber-50 border border-amber-200 text-amber-700 px-1.5 py-0.5 text-[10px] font-medium">
|
||||
{Icons.mousePointer("h-3 w-3")} Clicked
|
||||
</span>
|
||||
<AdminBadge variant="warning" dot>Clicked</AdminBadge>
|
||||
)}
|
||||
{log.bounced_at && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-red-50 border border-red-200 text-red-700 px-1.5 py-0.5 text-[10px] font-medium">
|
||||
{Icons.x("h-3 w-3")} Bounced
|
||||
</span>
|
||||
<AdminBadge variant="danger" dot>Bounced</AdminBadge>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
@@ -308,38 +311,32 @@ export default function MessageLogPanel({ brandId }: { brandId?: string }) {
|
||||
<div className="text-sm font-semibold text-[var(--admin-text-primary)]">{log.customer_email ?? "—"}</div>
|
||||
<div className="text-xs text-[var(--admin-text-muted)] mt-0.5">{log.subject ?? "—"}</div>
|
||||
</div>
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold shrink-0 ${STATUS_COLORS[log.status] ?? "bg-stone-100 text-stone-600"}`}>
|
||||
{log.status}
|
||||
</span>
|
||||
{(() => {
|
||||
const props = getStatusBadgeProps(log.status);
|
||||
return <AdminBadge variant={props.variant} dot>{log.status}</AdminBadge>;
|
||||
})()}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold ${METHOD_COLORS[log.delivery_method] ?? "bg-stone-100 text-stone-600"}`}>
|
||||
{log.delivery_method}
|
||||
</span>
|
||||
{(() => {
|
||||
const props = getMethodBadgeProps(log.delivery_method);
|
||||
return <AdminBadge variant={props.variant} dot>{log.delivery_method}</AdminBadge>;
|
||||
})()}
|
||||
<span className="text-xs text-[var(--admin-text-muted)]">
|
||||
{log.sent_at ? formatDate(log.sent_at) : "—"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{log.delivered_at && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-50 border border-emerald-200 text-emerald-700 px-1.5 py-0.5 text-[10px] font-medium">
|
||||
{Icons.check("h-3 w-3")} Delivered
|
||||
</span>
|
||||
<AdminBadge variant="success" dot>Delivered</AdminBadge>
|
||||
)}
|
||||
{log.opened_at && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-violet-50 border border-violet-200 text-violet-700 px-1.5 py-0.5 text-[10px] font-medium">
|
||||
{Icons.eye("h-3 w-3")} Opened
|
||||
</span>
|
||||
<AdminBadge variant="info" dot>Opened</AdminBadge>
|
||||
)}
|
||||
{log.clicked_at && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-amber-50 border border-amber-200 text-amber-700 px-1.5 py-0.5 text-[10px] font-medium">
|
||||
{Icons.mousePointer("h-3 w-3")} Clicked
|
||||
</span>
|
||||
<AdminBadge variant="warning" dot>Clicked</AdminBadge>
|
||||
)}
|
||||
{log.bounced_at && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-red-50 border border-red-200 text-red-700 px-1.5 py-0.5 text-[10px] font-medium">
|
||||
{Icons.x("h-3 w-3")} Bounced
|
||||
</span>
|
||||
<AdminBadge variant="danger" dot>Bounced</AdminBadge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useState, useRef } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { uploadProductImage } from "@/actions/products/upload-image";
|
||||
import { createProduct } from "@/actions/products/create-product";
|
||||
import { AdminInput, AdminTextInput, AdminTextarea, AdminSelect } from "./design-system";
|
||||
|
||||
export default function NewProductForm() {
|
||||
const router = useRouter();
|
||||
@@ -16,6 +17,16 @@ export default function NewProductForm() {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [pendingImageUrl, setPendingImageUrl] = useState<string>("");
|
||||
|
||||
// Form state
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [price, setPrice] = useState("");
|
||||
const [type, setType] = useState("Pickup");
|
||||
const [brandId, setBrandId] = useState("");
|
||||
const [isTaxable, setIsTaxable] = useState("true");
|
||||
const [pickupType, setPickupType] = useState("scheduled_stop");
|
||||
const [active, setActive] = useState("true");
|
||||
|
||||
async function handleFileSelect(file: File) {
|
||||
const validTypes = ["image/png", "image/jpeg", "image/webp"];
|
||||
if (!validTypes.includes(file.type)) {
|
||||
@@ -71,33 +82,23 @@ export default function NewProductForm() {
|
||||
});
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
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,
|
||||
price: parseFloat(price),
|
||||
type,
|
||||
active,
|
||||
active: active === "true",
|
||||
image_url: imageUrl,
|
||||
is_taxable: isTaxable,
|
||||
pickup_type,
|
||||
is_taxable: isTaxable === "true",
|
||||
pickup_type: pickupType,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
@@ -118,117 +119,92 @@ export default function NewProductForm() {
|
||||
</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"
|
||||
<AdminInput label="Product Name" required>
|
||||
<AdminTextInput
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g. Dozen Sweet Corn"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
</AdminInput>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
name="description"
|
||||
<AdminInput label="Description">
|
||||
<AdminTextarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
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>
|
||||
</AdminInput>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300">
|
||||
Price ($)
|
||||
</label>
|
||||
<input
|
||||
name="price"
|
||||
<AdminInput label="Price ($)" required>
|
||||
<AdminTextInput
|
||||
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"
|
||||
value={price}
|
||||
onChange={(e) => setPrice(e.target.value)}
|
||||
placeholder="12.00"
|
||||
/>
|
||||
</div>
|
||||
</AdminInput>
|
||||
|
||||
<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>
|
||||
<AdminInput label="Type" required>
|
||||
<AdminSelect
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value)}
|
||||
options={[
|
||||
{ value: "Pickup", label: "Pickup" },
|
||||
{ value: "Shipping", label: "Shipping" },
|
||||
{ value: "Pickup & Shipping", label: "Pickup & Shipping" },
|
||||
]}
|
||||
/>
|
||||
</AdminInput>
|
||||
</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>
|
||||
<AdminInput label="Brand" required>
|
||||
<AdminSelect
|
||||
value={brandId}
|
||||
onChange={(e) => setBrandId(e.target.value)}
|
||||
options={[
|
||||
{ value: "", label: "Select brand..." },
|
||||
{ value: "64294306-5f42-463d-a5e8-2ad6c81a96de", label: "Tuxedo Corn" },
|
||||
{ value: "b1cb7a96-d82b-40b1-80b1-d6dd26c56e28", label: "Indian River Direct" },
|
||||
]}
|
||||
/>
|
||||
</AdminInput>
|
||||
|
||||
<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>
|
||||
<AdminInput label="Taxable" helpText="Tax applied at checkout for shipping orders in nexus states. Disable for non-taxable items like apparel or cooler boxes.">
|
||||
<AdminSelect
|
||||
value={isTaxable}
|
||||
onChange={(e) => setIsTaxable(e.target.value)}
|
||||
options={[
|
||||
{ value: "true", label: "Yes — taxable (default)" },
|
||||
{ value: "false", label: "No — non-taxable" },
|
||||
]}
|
||||
/>
|
||||
</AdminInput>
|
||||
|
||||
<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>
|
||||
<AdminInput label="Pickup Type" helpText="Shed pickup uses the product description as the location — no stop required at checkout.">
|
||||
<AdminSelect
|
||||
value={pickupType}
|
||||
onChange={(e) => setPickupType(e.target.value)}
|
||||
options={[
|
||||
{ value: "scheduled_stop", label: "Scheduled Stop — requires stop selection at checkout" },
|
||||
{ value: "shed", label: "Shed Pickup — uses description as location" },
|
||||
]}
|
||||
/>
|
||||
</AdminInput>
|
||||
|
||||
<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>
|
||||
<AdminInput label="Active">
|
||||
<AdminSelect
|
||||
value={active}
|
||||
onChange={(e) => setActive(e.target.value)}
|
||||
options={[
|
||||
{ value: "true", label: "Yes — show on storefront" },
|
||||
{ value: "false", label: "No — hide from storefront" },
|
||||
]}
|
||||
/>
|
||||
</AdminInput>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300">Product Image</label>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { createStop } from "@/actions/stops/create-stop";
|
||||
import { AdminInput, AdminTextInput, AdminSelect } from "./design-system";
|
||||
|
||||
type Stop = {
|
||||
city: string;
|
||||
@@ -28,30 +29,33 @@ export default function NewStopForm({ duplicateFrom }: Props) {
|
||||
|
||||
const defaultBrand = duplicateFrom?.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
// Form state
|
||||
const [city, setCity] = useState(duplicateFrom?.city ?? "");
|
||||
const [state, setState] = useState(duplicateFrom?.state ?? "");
|
||||
const [location, setLocation] = useState(duplicateFrom?.location ?? "");
|
||||
const [date, setDate] = useState(duplicateFrom?.date ?? "");
|
||||
const [time, setTime] = useState(duplicateFrom?.time ?? "");
|
||||
const [brandId, setBrandId] = useState(defaultBrand);
|
||||
const [active, setActive] = useState("true");
|
||||
const [address, setAddress] = useState(duplicateFrom?.address ?? "");
|
||||
const [zip, setZip] = useState(duplicateFrom?.zip ?? "");
|
||||
const [cutoffTime, setCutoffTime] = useState(duplicateFrom?.cutoff_time ?? "");
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
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,
|
||||
active: active === "true",
|
||||
address: address || null,
|
||||
zip: zip || null,
|
||||
cutoff_time: cutoffTime || null,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
@@ -77,127 +81,97 @@ export default function NewStopForm({ duplicateFrom }: Props) {
|
||||
)}
|
||||
|
||||
<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"
|
||||
<AdminInput label="City" required>
|
||||
<AdminTextInput
|
||||
value={city}
|
||||
onChange={(e) => setCity(e.target.value)}
|
||||
placeholder="e.g. Denver"
|
||||
/>
|
||||
</div>
|
||||
</AdminInput>
|
||||
|
||||
<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"
|
||||
<AdminInput label="State" required>
|
||||
<AdminTextInput
|
||||
value={state}
|
||||
onChange={(e) => setState(e.target.value)}
|
||||
placeholder="e.g. CO"
|
||||
/>
|
||||
</div>
|
||||
</AdminInput>
|
||||
</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"
|
||||
<AdminInput label="Location Name" required>
|
||||
<AdminTextInput
|
||||
value={location}
|
||||
onChange={(e) => setLocation(e.target.value)}
|
||||
placeholder="e.g. Southwest Plaza Parking Lot"
|
||||
/>
|
||||
</div>
|
||||
</AdminInput>
|
||||
|
||||
<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
|
||||
<AdminInput label="Date" required>
|
||||
<AdminTextInput
|
||||
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"
|
||||
value={date}
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</AdminInput>
|
||||
|
||||
<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"
|
||||
<AdminInput label="Time" required>
|
||||
<AdminTextInput
|
||||
value={time}
|
||||
onChange={(e) => setTime(e.target.value)}
|
||||
placeholder="e.g. 8:00 AM – 2:00 PM"
|
||||
/>
|
||||
</div>
|
||||
</AdminInput>
|
||||
</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>
|
||||
<AdminInput label="Brand" required>
|
||||
<AdminSelect
|
||||
value={brandId}
|
||||
onChange={(e) => setBrandId(e.target.value)}
|
||||
options={[
|
||||
{ value: "", label: "Select brand..." },
|
||||
{ value: "64294306-5f42-463d-a5e8-2ad6c81a96de", label: "Tuxedo Corn" },
|
||||
{ value: "b1cb7a96-d82b-40b1-80b1-d6dd26c56e28", label: "Indian River Direct" },
|
||||
]}
|
||||
/>
|
||||
</AdminInput>
|
||||
|
||||
<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>
|
||||
<AdminInput label="Active">
|
||||
<AdminSelect
|
||||
value={active}
|
||||
onChange={(e) => setActive(e.target.value)}
|
||||
options={[
|
||||
{ value: "true", label: "Yes — show on storefront" },
|
||||
{ value: "false", label: "No — hide from storefront" },
|
||||
]}
|
||||
/>
|
||||
</AdminInput>
|
||||
|
||||
<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"
|
||||
<AdminInput label="Street Address">
|
||||
<AdminTextInput
|
||||
value={address}
|
||||
onChange={(e) => setAddress(e.target.value)}
|
||||
placeholder="123 Main St"
|
||||
/>
|
||||
</div>
|
||||
</AdminInput>
|
||||
|
||||
<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"
|
||||
<AdminInput label="ZIP Code">
|
||||
<AdminTextInput
|
||||
value={zip}
|
||||
onChange={(e) => setZip(e.target.value)}
|
||||
placeholder="80102"
|
||||
/>
|
||||
</div>
|
||||
</AdminInput>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300">Order Cutoff</label>
|
||||
<input
|
||||
name="cutoff_time"
|
||||
<AdminInput label="Order Cutoff" helpText="Customers must order before this time to be included at this stop.">
|
||||
<AdminTextInput
|
||||
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"
|
||||
value={cutoffTime}
|
||||
onChange={(e) => setCutoffTime(e.target.value)}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-slate-400">
|
||||
Customers must order before this time to be included at this stop.
|
||||
</p>
|
||||
</div>
|
||||
</AdminInput>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { updateOrder, updateOrderItem, deleteOrderItem } from "@/actions/orders/update-order";
|
||||
import { AdminInput, AdminTextInput, AdminTextarea } from "./design-system";
|
||||
|
||||
type OrderItem = {
|
||||
id: string;
|
||||
@@ -199,8 +200,7 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-stone-500">Qty</label>
|
||||
<AdminInput label="Qty">
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
@@ -210,10 +210,9 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
|
||||
}
|
||||
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-3 py-2 text-sm outline-none focus:border-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-stone-500">Price</label>
|
||||
<input
|
||||
</AdminInput>
|
||||
<AdminInput label="Price">
|
||||
<AdminTextInput
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
@@ -221,9 +220,8 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
|
||||
onChange={(e) =>
|
||||
updateItem(item.id, "price", Number(e.target.value))
|
||||
}
|
||||
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-3 py-2 text-sm outline-none focus:border-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
</AdminInput>
|
||||
</div>
|
||||
|
||||
<p className="mt-2 text-right text-sm font-semibold text-stone-900">
|
||||
@@ -237,8 +235,7 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
|
||||
|
||||
{/* Pricing */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-semibold text-stone-500">Subtotal (auto-calculated)</label>
|
||||
<AdminInput label="Subtotal (auto-calculated)">
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
@@ -246,30 +243,25 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
|
||||
readOnly
|
||||
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-2.5 text-sm text-stone-500"
|
||||
/>
|
||||
</div>
|
||||
</AdminInput>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-semibold text-stone-500">Discount Amount</label>
|
||||
<input
|
||||
<AdminInput label="Discount Amount">
|
||||
<AdminTextInput
|
||||
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-stone-200 bg-stone-50 px-4 py-2.5 text-sm outline-none focus:border-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-semibold text-stone-500">Discount Reason</label>
|
||||
<input
|
||||
type="text"
|
||||
</AdminInput>
|
||||
<AdminInput label="Discount Reason">
|
||||
<AdminTextInput
|
||||
value={discount_reason}
|
||||
onChange={(e) => setDiscount_reason(e.target.value)}
|
||||
placeholder="Optional"
|
||||
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-2.5 text-sm outline-none focus:border-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
</AdminInput>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-stone-200 bg-stone-50 p-4">
|
||||
@@ -284,34 +276,27 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
|
||||
|
||||
{/* Customer fields */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-semibold text-stone-500">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
<AdminInput label="Name">
|
||||
<AdminTextInput
|
||||
value={customer_name}
|
||||
onChange={(e) => setCustomer_name(e.target.value)}
|
||||
className="w-full rounded-xl border border-stone-200 bg-white px-4 py-2.5 text-sm outline-none focus:border-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
</AdminInput>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-semibold text-stone-500">Email</label>
|
||||
<input
|
||||
<AdminInput label="Email">
|
||||
<AdminTextInput
|
||||
type="email"
|
||||
value={customer_email}
|
||||
onChange={(e) => setCustomer_email(e.target.value)}
|
||||
className="w-full rounded-xl border border-stone-200 bg-white px-4 py-2.5 text-sm outline-none focus:border-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-semibold text-stone-500">Phone</label>
|
||||
<input
|
||||
</AdminInput>
|
||||
<AdminInput label="Phone">
|
||||
<AdminTextInput
|
||||
type="tel"
|
||||
value={customer_phone}
|
||||
onChange={(e) => setCustomer_phone(e.target.value)}
|
||||
className="w-full rounded-xl border border-stone-200 bg-white px-4 py-2.5 text-sm outline-none focus:border-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
</AdminInput>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -352,16 +337,14 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
|
||||
</div>
|
||||
|
||||
{/* Internal notes */}
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-semibold text-stone-500">Internal Notes</label>
|
||||
<textarea
|
||||
<AdminInput label="Internal Notes">
|
||||
<AdminTextarea
|
||||
value={internal_notes}
|
||||
onChange={(e) => setInternal_notes(e.target.value)}
|
||||
rows={2}
|
||||
placeholder="Private notes for staff..."
|
||||
className="w-full rounded-xl border border-stone-200 bg-white px-4 py-2.5 text-sm outline-none focus:border-emerald-500 resize-none"
|
||||
/>
|
||||
</div>
|
||||
</AdminInput>
|
||||
|
||||
<button
|
||||
onClick={handleSave}
|
||||
|
||||
@@ -4,6 +4,7 @@ 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";
|
||||
import { AdminInput, AdminTextInput, AdminSelect } from "./design-system";
|
||||
|
||||
type InventoryMode = "none" | "rc_to_square" | "square_to_rc" | "bidirectional";
|
||||
|
||||
@@ -153,18 +154,13 @@ export default function PaymentSettingsForm({ settings, brandId, brands = [], is
|
||||
<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
|
||||
<AdminInput label="Brand">
|
||||
<AdminSelect
|
||||
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>
|
||||
options={brands.map((b) => ({ value: b.id, label: b.name }))}
|
||||
/>
|
||||
</AdminInput>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
@@ -297,16 +293,13 @@ export default function PaymentSettingsForm({ settings, brandId, brands = [], is
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300">Location ID</label>
|
||||
<input
|
||||
type="text"
|
||||
<AdminInput label="Location ID">
|
||||
<AdminTextInput
|
||||
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>
|
||||
</AdminInput>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDisconnectSquare}
|
||||
|
||||
@@ -5,6 +5,7 @@ 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";
|
||||
import { AdminInput, AdminTextInput, AdminTextarea, AdminSelect } from "./design-system";
|
||||
|
||||
type Brand = {
|
||||
id: string;
|
||||
@@ -157,65 +158,51 @@ export default function ProductEditForm({ product, brands }: ProductEditFormProp
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-zinc-300">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
<AdminInput label="Name">
|
||||
<AdminTextInput
|
||||
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"
|
||||
placeholder="Product name"
|
||||
/>
|
||||
</div>
|
||||
</AdminInput>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-zinc-300">Description</label>
|
||||
<textarea
|
||||
<AdminInput label="Description">
|
||||
<AdminTextarea
|
||||
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"
|
||||
placeholder="Product description"
|
||||
/>
|
||||
</div>
|
||||
</AdminInput>
|
||||
|
||||
<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>
|
||||
<AdminInput label="Price">
|
||||
<AdminTextInput
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={price}
|
||||
onChange={(e) => setPrice(Number(e.target.value))}
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</AdminInput>
|
||||
|
||||
<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>
|
||||
<AdminInput label="Type">
|
||||
<AdminTextInput
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value)}
|
||||
placeholder="e.g. Sweet Corn"
|
||||
/>
|
||||
</AdminInput>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-zinc-300">Brand</label>
|
||||
<select
|
||||
<AdminInput label="Brand">
|
||||
<AdminSelect
|
||||
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>
|
||||
options={brands.map((b) => ({ value: b.id, label: b.name }))}
|
||||
/>
|
||||
</AdminInput>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-zinc-300">Status</label>
|
||||
@@ -247,18 +234,19 @@ export default function ProductEditForm({ product, brands }: ProductEditFormProp
|
||||
<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"
|
||||
<AdminInput
|
||||
label="Pickup Type"
|
||||
helpText="Shed pickup uses the product description as the pickup location — no stop required at checkout."
|
||||
>
|
||||
<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>
|
||||
<AdminSelect
|
||||
value={pickup_type}
|
||||
onChange={(e) => setPickup_type(e.target.value)}
|
||||
options={[
|
||||
{ value: "scheduled_stop", label: "Scheduled Stop — requires stop selection at checkout" },
|
||||
{ value: "shed", label: "Shed Pickup — uses description as location" },
|
||||
]}
|
||||
/>
|
||||
</AdminInput>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-zinc-300">Product Image</label>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { deleteProduct } from "@/actions/products";
|
||||
import AdminBadge from "./design-system/AdminBadge";
|
||||
|
||||
type Product = {
|
||||
id: string;
|
||||
@@ -146,22 +147,16 @@ export default function ProductTableBody({
|
||||
</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"
|
||||
}`}
|
||||
>
|
||||
<AdminBadge variant={product.active ? "success" : "default"} dot>
|
||||
{product.active ? "Active" : "Inactive"}
|
||||
</span>
|
||||
</AdminBadge>
|
||||
</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>
|
||||
<AdminBadge variant="warning">Non-taxable</AdminBadge>
|
||||
) : (
|
||||
<span className="rounded-full bg-emerald-100 px-2.5 py-1 text-xs font-medium text-emerald-700">Taxable</span>
|
||||
<AdminBadge variant="success">Taxable</AdminBadge>
|
||||
)}
|
||||
</td>
|
||||
|
||||
@@ -181,7 +176,9 @@ export default function ProductTableBody({
|
||||
}}
|
||||
className="rounded-lg px-2 py-1.5 text-xs text-zinc-500 hover:bg-zinc-950"
|
||||
>
|
||||
⋮
|
||||
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<circle cx="10" cy="4" r="1.5"/><circle cx="10" cy="10" r="1.5"/><circle cx="10" cy="16" r="1.5"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{openMenu === product.id && (
|
||||
@@ -225,7 +222,7 @@ export default function ProductTableBody({
|
||||
<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"
|
||||
className="flex-1 rounded-lg bg-[var(--admin-danger)] px-3 py-2 text-sm font-medium text-white hover:bg-[var(--admin-danger-hover)] disabled:opacity-50"
|
||||
>
|
||||
{deletingId === product.id ? "..." : "Delete"}
|
||||
</button>
|
||||
|
||||
@@ -224,7 +224,9 @@ function ProductRowBase({
|
||||
}}
|
||||
className="rounded-lg px-2 py-1.5 text-xs text-stone-400 hover:text-stone-600 hover:bg-stone-100 transition-colors"
|
||||
>
|
||||
⋮
|
||||
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<circle cx="10" cy="4" r="1.5"/><circle cx="10" cy="10" r="1.5"/><circle cx="10" cy="16" r="1.5"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{openMenu === product.id && (
|
||||
|
||||
@@ -8,6 +8,7 @@ import { updateProduct } from "@/actions/products/update-product";
|
||||
import { deleteProduct } from "@/actions/products";
|
||||
import { uploadProductImage } from "@/actions/products/upload-image";
|
||||
import GlassModal from "@/components/admin/GlassModal";
|
||||
import { PageHeader, AdminButton, AdminIconButton, AdminSearchInput, AdminFilterTabs, AdminViewModeTabs } from "@/components/admin/design-system";
|
||||
|
||||
type Product = {
|
||||
id: string;
|
||||
@@ -33,11 +34,6 @@ type FormData = {
|
||||
|
||||
type ViewMode = "table" | "cards";
|
||||
|
||||
type Props = {
|
||||
products: Product[];
|
||||
brandId: string;
|
||||
};
|
||||
|
||||
// Icons
|
||||
const Icons = {
|
||||
search: (className: string) => (
|
||||
@@ -46,24 +42,6 @@ const Icons = {
|
||||
<path d="m21 21-4.3-4.3"/>
|
||||
</svg>
|
||||
),
|
||||
grid: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="3" width="7" height="7"/>
|
||||
<rect x="14" y="3" width="7" height="7"/>
|
||||
<rect x="14" y="14" width="7" height="7"/>
|
||||
<rect x="3" y="14" width="7" height="7"/>
|
||||
</svg>
|
||||
),
|
||||
list: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="8" y1="6" x2="21" y2="6"/>
|
||||
<line x1="8" y1="12" x2="21" y2="12"/>
|
||||
<line x1="8" y1="18" x2="21" y2="18"/>
|
||||
<line x1="3" y1="6" x2="3.01" y2="6"/>
|
||||
<line x1="3" y1="12" x2="3.01" y2="12"/>
|
||||
<line x1="3" y1="18" x2="3.01" y2="18"/>
|
||||
</svg>
|
||||
),
|
||||
plus: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 5v14M5 12h14"/>
|
||||
@@ -86,7 +64,17 @@ const Icons = {
|
||||
),
|
||||
};
|
||||
|
||||
export default function ProductsClient({ products, brandId }: Props) {
|
||||
// Page header icon
|
||||
const PackageIconHeader = () => (
|
||||
<svg className="h-6 w-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="m7.5 4.27 9 5.15"/>
|
||||
<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/>
|
||||
<path d="m3.3 7 8.7 5 8.7-5"/>
|
||||
<path d="M12 22V12"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default function ProductsClient({ products, brandId }: { products: Product[]; brandId: string }) {
|
||||
const router = useRouter();
|
||||
const [, startTransition] = useTransition();
|
||||
const [search, setSearch] = useState("");
|
||||
@@ -295,29 +283,29 @@ export default function ProductsClient({ products, brandId }: Props) {
|
||||
}
|
||||
};
|
||||
|
||||
// Filter tabs configuration
|
||||
const filterTabs = [
|
||||
{ value: "all", label: "All", count: products.length },
|
||||
{ value: "active", label: "Active", count: activeCount },
|
||||
{ value: "inactive", label: "Inactive", count: inactiveCount },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="p-4 sm:p-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-emerald-600">
|
||||
{Icons.package("h-5 w-5 text-white")}
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base sm:text-lg font-bold text-[var(--admin-text-primary)]">Products</h2>
|
||||
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)]">
|
||||
{filtered.length} product{filtered.length !== 1 ? "s" : ""}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={openAddModal}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-emerald-600 text-white text-sm font-medium rounded-xl hover:bg-emerald-700 transition-colors"
|
||||
>
|
||||
{Icons.plus("h-4 w-4")}
|
||||
Add Product
|
||||
</button>
|
||||
</div>
|
||||
{/* Page Header */}
|
||||
<PageHeader
|
||||
icon={<PackageIconHeader />}
|
||||
title="Products"
|
||||
subtitle={`${filtered.length} product${filtered.length !== 1 ? "s" : ""}`}
|
||||
actions={
|
||||
<AdminButton
|
||||
onClick={openAddModal}
|
||||
icon={Icons.plus("h-4 w-4")}
|
||||
>
|
||||
Add Product
|
||||
</AdminButton>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-3 gap-3 mb-6">
|
||||
@@ -327,7 +315,7 @@ export default function ProductsClient({ products, brandId }: Props) {
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-4">
|
||||
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium">Active</p>
|
||||
<p className="text-xl sm:text-2xl font-bold text-emerald-600 mt-1">{activeCount}</p>
|
||||
<p className="text-xl sm:text-2xl font-bold text-[var(--admin-accent)] mt-1">{activeCount}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-4">
|
||||
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium">Inactive</p>
|
||||
@@ -338,53 +326,27 @@ export default function ProductsClient({ products, brandId }: Props) {
|
||||
{/* Filters */}
|
||||
<div className="flex flex-col sm:flex-row gap-3 mb-6">
|
||||
{/* Status Tabs */}
|
||||
<div className="flex rounded-xl border border-[var(--admin-border)] bg-white p-1">
|
||||
{(["all", "active", "inactive"] as const).map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setStatusFilter(f)}
|
||||
className={`rounded-lg px-4 py-2 text-sm font-medium transition-colors ${
|
||||
statusFilter === f
|
||||
? "bg-emerald-600 text-white"
|
||||
: "text-stone-500 hover:bg-stone-50"
|
||||
}`}
|
||||
>
|
||||
{f === "all" ? "All" : f === "active" ? "Active" : "Inactive"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<AdminFilterTabs
|
||||
activeTab={statusFilter}
|
||||
onTabChange={(value) => setStatusFilter(value as "all" | "active" | "inactive")}
|
||||
tabs={filterTabs}
|
||||
size="md"
|
||||
/>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative flex-1">
|
||||
{Icons.search("absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-stone-400")}
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search products..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2.5 text-sm border border-[var(--admin-border)] rounded-xl bg-white text-[var(--admin-text-primary)] placeholder:text-stone-400 focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
<AdminSearchInput
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onClear={() => setSearch("")}
|
||||
placeholder="Search products..."
|
||||
containerClassName="flex-1"
|
||||
/>
|
||||
|
||||
{/* View Toggle */}
|
||||
<div className="flex rounded-xl border border-[var(--admin-border)] bg-white p-1">
|
||||
<button
|
||||
onClick={() => setViewMode("table")}
|
||||
className={`rounded-lg p-2 transition-colors ${
|
||||
viewMode === "table" ? "bg-emerald-600 text-white" : "text-stone-500 hover:bg-stone-50"
|
||||
}`}
|
||||
>
|
||||
{Icons.list("h-4 w-4")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode("cards")}
|
||||
className={`rounded-lg p-2 transition-colors ${
|
||||
viewMode === "cards" ? "bg-emerald-600 text-white" : "text-stone-500 hover:bg-stone-50"
|
||||
}`}
|
||||
>
|
||||
{Icons.grid("h-4 w-4")}
|
||||
</button>
|
||||
</div>
|
||||
<AdminViewModeTabs
|
||||
activeTab={viewMode}
|
||||
onTabChange={(value) => setViewMode(value as ViewMode)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
@@ -431,7 +393,7 @@ export default function ProductsClient({ products, brandId }: Props) {
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="Product name"
|
||||
className="w-full rounded-xl border border-[var(--admin-border)] bg-stone-50 px-3 py-2.5 text-sm outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20"
|
||||
className="w-full rounded-xl border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20 placeholder:text-[var(--admin-text-muted)]"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@@ -443,7 +405,7 @@ export default function ProductsClient({ products, brandId }: Props) {
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
placeholder="Product description"
|
||||
rows={2}
|
||||
className="w-full rounded-xl border border-[var(--admin-border)] bg-stone-50 px-3 py-2.5 text-sm outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 resize-none"
|
||||
className="w-full rounded-xl border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20 placeholder:text-[var(--admin-text-muted)] resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -459,7 +421,7 @@ export default function ProductsClient({ products, brandId }: Props) {
|
||||
value={formData.price}
|
||||
onChange={(e) => setFormData({ ...formData, price: e.target.value })}
|
||||
placeholder="0.00"
|
||||
className="w-full rounded-xl border border-[var(--admin-border)] bg-stone-50 pl-8 pr-3 py-2.5 text-sm outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20"
|
||||
className="w-full rounded-xl border border-[var(--admin-border)] bg-white pl-8 pr-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@@ -469,7 +431,7 @@ export default function ProductsClient({ products, brandId }: Props) {
|
||||
<select
|
||||
value={formData.type}
|
||||
onChange={(e) => setFormData({ ...formData, type: e.target.value })}
|
||||
className="w-full rounded-xl border border-[var(--admin-border)] bg-stone-50 px-3 py-2.5 text-sm outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20"
|
||||
className="w-full rounded-xl border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20"
|
||||
>
|
||||
<option value="pickup">Pickup</option>
|
||||
<option value="wholesale">Wholesale</option>
|
||||
@@ -493,13 +455,13 @@ export default function ProductsClient({ products, brandId }: Props) {
|
||||
onClick={() => !uploading && 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-[var(--admin-border)] hover:border-emerald-400 hover:bg-emerald-50/50"}
|
||||
${uploadError ? "border-red-400" : "border-[var(--admin-border)] hover:border-[var(--admin-accent)] hover:bg-[var(--admin-accent)]/5"}
|
||||
${uploading ? "opacity-50 pointer-events-none" : ""}
|
||||
`}
|
||||
>
|
||||
{uploading ? (
|
||||
<>
|
||||
<div className="h-5 w-5 rounded-full border-2 border-emerald-400 border-t-transparent animate-spin" />
|
||||
<div className="h-5 w-5 rounded-full border-2 border-[var(--admin-accent)] border-t-transparent animate-spin" />
|
||||
<span className="text-sm text-stone-500">Uploading...</span>
|
||||
</>
|
||||
) : imagePreview ? (
|
||||
@@ -553,7 +515,7 @@ export default function ProductsClient({ products, brandId }: Props) {
|
||||
type="checkbox"
|
||||
checked={formData.active}
|
||||
onChange={(e) => setFormData({ ...formData, active: e.target.checked })}
|
||||
className="h-4 w-4 rounded border-stone-300 text-emerald-600"
|
||||
className="h-4 w-4 rounded border-[var(--admin-border)] text-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/30 focus:ring-offset-1 cursor-pointer"
|
||||
/>
|
||||
<span className="text-sm font-medium text-stone-700">Active</span>
|
||||
</label>
|
||||
@@ -562,27 +524,28 @@ export default function ProductsClient({ products, brandId }: Props) {
|
||||
type="checkbox"
|
||||
checked={formData.is_taxable}
|
||||
onChange={(e) => setFormData({ ...formData, is_taxable: e.target.checked })}
|
||||
className="h-4 w-4 rounded border-stone-300 text-emerald-600"
|
||||
className="h-4 w-4 rounded border-[var(--admin-border)] text-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/30 focus:ring-offset-1 cursor-pointer"
|
||||
/>
|
||||
<span className="text-sm font-medium text-stone-700">Taxable</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4 border-t border-stone-100">
|
||||
<button
|
||||
<AdminButton
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={closeModal}
|
||||
className="rounded-xl border border-[var(--admin-border)] px-4 py-2 text-sm font-semibold text-stone-600 hover:bg-stone-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
</AdminButton>
|
||||
<AdminButton
|
||||
type="submit"
|
||||
disabled={saving || !formData.name.trim() || !formData.price}
|
||||
className="rounded-xl bg-emerald-600 px-5 py-2 text-sm font-bold text-white hover:bg-emerald-700 disabled:opacity-50"
|
||||
variant="primary"
|
||||
isLoading={saving}
|
||||
disabled={!formData.name.trim() || !formData.price}
|
||||
>
|
||||
{saving ? "Saving..." : editingProduct ? "Save Changes" : "Create Product"}
|
||||
</button>
|
||||
{editingProduct ? "Save Changes" : "Create Product"}
|
||||
</AdminButton>
|
||||
</div>
|
||||
</form>
|
||||
</GlassModal>
|
||||
@@ -656,7 +619,7 @@ function TableView({
|
||||
<div>
|
||||
<button
|
||||
onClick={() => onEdit(product)}
|
||||
className="font-semibold text-[var(--admin-text-primary)] hover:text-emerald-600 transition-colors text-left"
|
||||
className="font-semibold text-[var(--admin-text-primary)] hover:text-[var(--admin-accent)] transition-colors text-left"
|
||||
>
|
||||
{product.name}
|
||||
</button>
|
||||
@@ -679,7 +642,7 @@ function TableView({
|
||||
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-[10px] font-semibold ${
|
||||
product.active ? "bg-emerald-100 text-emerald-700" : "bg-stone-100 text-stone-500"
|
||||
product.active ? "bg-[var(--admin-accent)]/10 text-[var(--admin-accent)]" : "bg-stone-100 text-stone-500"
|
||||
}`}>
|
||||
{product.active ? "Active" : "Inactive"}
|
||||
</span>
|
||||
@@ -687,17 +650,20 @@ function TableView({
|
||||
|
||||
<td className="px-4 py-3 text-right">
|
||||
<div className="relative inline-flex items-center justify-end gap-1">
|
||||
<button
|
||||
<AdminButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onEdit(product)}
|
||||
className="rounded-lg px-3 py-1.5 text-xs font-semibold text-stone-600 hover:bg-stone-100 transition-colors"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</AdminButton>
|
||||
<button
|
||||
onClick={() => onDelete(product.id)}
|
||||
className="rounded-lg px-2 py-1.5 text-xs text-stone-400 hover:text-red-600 hover:bg-red-50 transition-colors"
|
||||
className="rounded-lg px-2 py-1.5 text-xs text-[var(--admin-text-muted)] hover:text-red-600 hover:bg-red-50 transition-colors"
|
||||
>
|
||||
⋮
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{deleteConfirm === product.id && (
|
||||
@@ -717,13 +683,15 @@ function TableView({
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
<AdminButton
|
||||
onClick={() => onDeleteConfirm(product.id)}
|
||||
disabled={deletingId === product.id}
|
||||
className="flex-1 rounded-lg bg-red-600 px-3 py-2 text-xs font-bold text-white disabled:opacity-50 hover:bg-red-500"
|
||||
variant="danger"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
>
|
||||
{deletingId === product.id ? "..." : "Delete"}
|
||||
</button>
|
||||
</AdminButton>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
@@ -772,7 +740,7 @@ function CardView({
|
||||
products.map((product) => (
|
||||
<div
|
||||
key={product.id}
|
||||
className="group relative rounded-xl border border-[var(--admin-border)] bg-white hover:shadow-md hover:border-emerald-200 transition-all overflow-hidden"
|
||||
className="group relative rounded-xl border border-[var(--admin-border)] bg-white hover:shadow-md hover:border-[var(--admin-accent)]/30 transition-all overflow-hidden"
|
||||
>
|
||||
{/* Image */}
|
||||
<div className="relative h-40 bg-stone-100">
|
||||
@@ -793,7 +761,7 @@ function CardView({
|
||||
{/* Status badge */}
|
||||
<div className="absolute top-3 right-3">
|
||||
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-[10px] font-semibold shadow-sm ${
|
||||
product.active ? "bg-emerald-500 text-white" : "bg-stone-500 text-white"
|
||||
product.active ? "bg-[var(--admin-accent)] text-white" : "bg-stone-500 text-white"
|
||||
}`}>
|
||||
{product.active ? "Active" : "Inactive"}
|
||||
</span>
|
||||
@@ -806,7 +774,7 @@ function CardView({
|
||||
onClick={() => onEdit(product)}
|
||||
className="block w-full text-left"
|
||||
>
|
||||
<h3 className="font-semibold text-[var(--admin-text-primary)] group-hover:text-emerald-600 transition-colors line-clamp-1">
|
||||
<h3 className="font-semibold text-[var(--admin-text-primary)] group-hover:text-[var(--admin-accent)] transition-colors line-clamp-1">
|
||||
{product.name}
|
||||
</h3>
|
||||
<p className="text-xs text-stone-500 mt-1 line-clamp-2 h-8">
|
||||
@@ -834,17 +802,21 @@ function CardView({
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mt-3">
|
||||
<button
|
||||
<AdminButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onEdit(product)}
|
||||
className="flex-1 rounded-lg bg-emerald-50 border border-emerald-200 px-3 py-2 text-xs font-semibold text-emerald-700 hover:bg-emerald-100 transition-colors"
|
||||
className="flex-1"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</AdminButton>
|
||||
<button
|
||||
onClick={() => onDelete(product.id)}
|
||||
className="rounded-lg border border-[var(--admin-border)] px-3 py-2 text-xs font-semibold text-stone-500 hover:bg-red-50 hover:text-red-600 hover:border-red-200 transition-colors"
|
||||
className="rounded-lg border border-[var(--admin-border)] px-3 py-2 text-xs font-semibold text-[var(--admin-text-muted)] hover:bg-red-50 hover:text-red-600 hover:border-red-200 transition-colors"
|
||||
>
|
||||
🗑
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" 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>
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
type CampaignActivityRow,
|
||||
} from "@/lib/reports-export";
|
||||
import { formatDate, formatDateRange } from "@/lib/date-utils";
|
||||
import { AdminButton, AdminFilterTabs } from "@/components/admin/design-system";
|
||||
|
||||
type DatePreset = "week" | "month" | "quarter" | "this_year" | "custom";
|
||||
|
||||
@@ -105,12 +106,13 @@ function ExportBtn({
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
<AdminButton
|
||||
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"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
>
|
||||
Export CSV
|
||||
</button>
|
||||
</AdminButton>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -122,13 +124,14 @@ function ExplainBtn({
|
||||
loading: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
<AdminButton
|
||||
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"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
>
|
||||
{loading ? "Analyzing..." : "🤖 Explain this Report"}
|
||||
</button>
|
||||
</AdminButton>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -169,19 +172,14 @@ function ReportTable({ headers, rows, renderRow }: {
|
||||
|
||||
// ── 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" },
|
||||
const TABS = [
|
||||
{ value: "overview", label: "Overview" },
|
||||
{ value: "orders-by-stop", label: "Orders by Stop" },
|
||||
{ value: "sales-by-product", label: "Sales by Product" },
|
||||
{ value: "fulfillment", label: "Fulfillment" },
|
||||
{ value: "pickup-status", label: "Pickup Status" },
|
||||
{ value: "contact-growth", label: "Contact Growth" },
|
||||
{ value: "campaigns", label: "Campaigns" },
|
||||
];
|
||||
|
||||
// ── Main component ──────────────────────────────────────────────────────────
|
||||
@@ -325,21 +323,18 @@ export default function ReportsDashboard({
|
||||
)}
|
||||
|
||||
{/* ── 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 flex-wrap items-center gap-3 rounded-xl border border-[var(--admin-border)] bg-[var(--admin-card-bg)] px-5 py-4">
|
||||
{/* Date preset */}
|
||||
<div className="flex gap-1.5">
|
||||
{(["week", "month", "quarter", "this_year", "custom"] as DatePreset[]).map((p) => (
|
||||
<button
|
||||
<AdminButton
|
||||
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"
|
||||
}`}
|
||||
variant={preset === p ? "primary" : "secondary"}
|
||||
size="sm"
|
||||
>
|
||||
{p === "week" ? "This Week" : p === "month" ? "This Month" : p === "quarter" ? "This Quarter" : p === "this_year" ? "This Year" : "Custom"}
|
||||
</button>
|
||||
</AdminButton>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -376,13 +371,15 @@ export default function ReportsDashboard({
|
||||
</select>
|
||||
)}
|
||||
|
||||
<button
|
||||
<AdminButton
|
||||
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"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
isLoading={loading}
|
||||
>
|
||||
{loading ? "Loading..." : "Refresh"}
|
||||
</button>
|
||||
Refresh
|
||||
</AdminButton>
|
||||
|
||||
<span className="ml-auto text-sm font-medium text-zinc-500">
|
||||
{preset === "quarter" ? quarterLabel(range.start) :
|
||||
@@ -394,21 +391,13 @@ export default function ReportsDashboard({
|
||||
</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>
|
||||
<AdminFilterTabs
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
tabs={TABS}
|
||||
size="md"
|
||||
showCounts={false}
|
||||
/>
|
||||
|
||||
{/* ── Tab content ──────────────────────────────────────────────────────── */}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import SettingsSections from "@/components/admin/SettingsSections";
|
||||
import UsersPage from "@/components/admin/UsersPage";
|
||||
import { PageHeader, AdminButton } from "@/components/admin/design-system";
|
||||
|
||||
type Tab = "general" | "workers" | "tasks" | "users";
|
||||
|
||||
@@ -77,41 +78,43 @@ export default function SettingsClient({
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--admin-bg)]">
|
||||
{/* Header */}
|
||||
<main className="min-h-screen bg-[var(--admin-bg)]">
|
||||
{/* Page Header */}
|
||||
<div className="px-4 sm:px-6 md:px-8 pt-4 sm:pt-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="flex h-10 w-10 sm:h-12 sm:w-12 items-center justify-center rounded-xl bg-emerald-600">
|
||||
{Icons.settings("h-5 w-5 sm:h-6 sm:w-6 text-white")}
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl sm:text-2xl font-black text-[var(--admin-text-primary)] tracking-tight">Settings</h1>
|
||||
<p className="text-xs text-[var(--admin-text-muted)]">Manage your brand, workers, and integrations</p>
|
||||
</div>
|
||||
</div>
|
||||
<PageHeader
|
||||
title="Settings"
|
||||
subtitle="Manage your brand, workers, and integrations"
|
||||
icon={
|
||||
<svg className="h-5 w-5 sm:h-6 sm:w-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/>
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
</svg>
|
||||
}
|
||||
actions={
|
||||
<AdminButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => window.location.href = "/admin/advanced"}
|
||||
>
|
||||
Advanced Settings
|
||||
</AdminButton>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Tab navigation */}
|
||||
<nav className="grid grid-cols-5 gap-1 p-1.5 rounded-xl bg-white border border-stone-200">
|
||||
<nav className="flex items-center gap-1 p-1.5 rounded-xl bg-white border border-[var(--admin-border)] overflow-x-auto">
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`relative flex flex-col sm:flex-row items-center justify-center gap-1 sm:gap-2 rounded-lg px-1 sm:px-3 py-2 text-[9px] sm:text-xs font-semibold transition-colors ${
|
||||
className={`relative flex items-center gap-2 rounded-lg px-4 py-2.5 text-xs font-semibold transition-all duration-150 whitespace-nowrap ${
|
||||
activeTab === tab.id
|
||||
? "bg-emerald-600 text-white"
|
||||
: "text-stone-500 hover:text-stone-700 hover:bg-stone-50"
|
||||
? "bg-[var(--admin-accent)] text-white shadow-sm"
|
||||
: "text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)] hover:bg-stone-50"
|
||||
}`}
|
||||
>
|
||||
{tab.icon === "settings" && Icons.settings("h-4 w-4")}
|
||||
{tab.icon === "users" && Icons.users("h-4 w-4")}
|
||||
{tab.icon === "user-check" && Icons["user-check"]("h-4 w-4")}
|
||||
{tab.icon === "plug" && Icons.plug("h-4 w-4")}
|
||||
{tab.icon === "list" && Icons.list("h-4 w-4")}
|
||||
<span className="hidden sm:inline">{tab.label}</span>
|
||||
<span className="sm:hidden">{tab.label.substring(0, 3)}</span>
|
||||
{activeTab === tab.id && (
|
||||
<div className="absolute bottom-1 sm:bottom-1.5 left-1/2 -translate-x-1/2 h-0.5 w-6 sm:w-8 bg-white rounded-full" />
|
||||
)}
|
||||
{/* icons */}
|
||||
<span>{tab.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
@@ -129,7 +132,7 @@ export default function SettingsClient({
|
||||
<div className="rounded-2xl border border-[var(--admin-border)] bg-white overflow-hidden">
|
||||
<div className="p-4 sm:p-6 border-b border-[var(--admin-border)]">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-emerald-600">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-[var(--admin-accent)]">
|
||||
{Icons.users("w-4 h-4 text-white")}
|
||||
</div>
|
||||
<div>
|
||||
@@ -190,28 +193,29 @@ export default function SettingsClient({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6 p-4 rounded-xl border border-violet-100 bg-violet-50/50">
|
||||
<div className="mt-6 p-4 rounded-xl border border-[var(--admin-accent)] bg-[var(--admin-accent-light)]">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-violet-100">
|
||||
<svg className="w-4 h-4 text-violet-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-[var(--admin-accent)]">
|
||||
<svg className="w-4 h-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-stone-900">Advanced Settings</p>
|
||||
<p className="text-xs text-stone-500">AI, integrations, Square sync, shipping, and webhooks</p>
|
||||
<p className="text-sm font-semibold text-[var(--admin-accent-text)]">Advanced Settings</p>
|
||||
<p className="text-xs text-[var(--admin-text-muted)]">AI, integrations, Square sync, shipping, and webhooks</p>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href="/admin/advanced"
|
||||
className="px-4 py-2 rounded-lg bg-violet-600 text-white text-sm font-semibold hover:bg-violet-500 transition-colors"
|
||||
<AdminButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => window.location.href = "/admin/advanced"}
|
||||
>
|
||||
Open Advanced →
|
||||
</a>
|
||||
</AdminButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -349,7 +349,9 @@ export default function SettingsSections({ brandId, workersOnly, tasksOnly }: Pr
|
||||
{/* 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>
|
||||
<svg className="h-5 w-5 text-amber-600 shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<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>
|
||||
@@ -402,7 +404,11 @@ export default function SettingsSections({ brandId, workersOnly, tasksOnly }: Pr
|
||||
{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>
|
||||
<button onClick={() => removeEmail(e)} className="text-stone-400 hover:text-red-500 ml-1">
|
||||
<svg className="h-3 w-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>
|
||||
@@ -421,7 +427,11 @@ export default function SettingsSections({ brandId, workersOnly, tasksOnly }: Pr
|
||||
{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>
|
||||
<button onClick={() => removeSms(n)} className="text-stone-400 hover:text-red-500 ml-1">
|
||||
<svg className="h-3 w-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>
|
||||
@@ -563,7 +573,11 @@ export default function SettingsSections({ brandId, workersOnly, tasksOnly }: Pr
|
||||
<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>
|
||||
<button onClick={() => setShowWorkerModal(false)} className="text-stone-400 hover:text-stone-600">
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</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>}
|
||||
@@ -631,7 +645,11 @@ export default function SettingsSections({ brandId, workersOnly, tasksOnly }: Pr
|
||||
<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>
|
||||
<button onClick={() => setShowTaskModal(false)} className="text-stone-400 hover:text-stone-600">
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</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>}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
testFedExConnection,
|
||||
type ShippingSettings,
|
||||
} from "@/actions/shipping/settings";
|
||||
import { AdminInput, AdminTextInput, AdminTextarea, AdminSelect } from "./design-system";
|
||||
|
||||
const SERVICE_OPTIONS = [
|
||||
{ value: "FEDEX_OVERNIGHT", label: "FedEx Overnight" },
|
||||
@@ -121,18 +122,13 @@ export default function ShippingSettingsForm({
|
||||
<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
|
||||
<AdminInput label="Brand">
|
||||
<AdminSelect
|
||||
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>
|
||||
options={brands.map((b) => ({ value: b.id, label: b.name }))}
|
||||
/>
|
||||
</AdminInput>
|
||||
)}
|
||||
|
||||
{/* Connection status banner */}
|
||||
@@ -180,45 +176,29 @@ export default function ShippingSettingsForm({
|
||||
</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"
|
||||
<AdminInput label="FedEx Account Number *">
|
||||
<AdminTextInput
|
||||
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"
|
||||
</AdminInput>
|
||||
<AdminInput label="API Key *">
|
||||
<AdminTextInput
|
||||
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>
|
||||
</AdminInput>
|
||||
</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
|
||||
<AdminInput label="API Secret *" helpText="Password field — click Show/Hide to reveal">
|
||||
<div className="relative">
|
||||
<AdminTextInput
|
||||
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"
|
||||
@@ -228,7 +208,7 @@ export default function ShippingSettingsForm({
|
||||
{showSecret ? "Hide" : "Show"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</AdminInput>
|
||||
|
||||
{/* Production toggle */}
|
||||
<div className="flex items-center gap-3 rounded-lg border border-zinc-700 bg-zinc-800 px-4 py-3">
|
||||
@@ -287,20 +267,13 @@ export default function ShippingSettingsForm({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-zinc-300">
|
||||
Default for non-perishable orders
|
||||
</label>
|
||||
<select
|
||||
<AdminInput label="Default for non-perishable orders">
|
||||
<AdminSelect
|
||||
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>
|
||||
options={SERVICE_OPTIONS}
|
||||
/>
|
||||
</AdminInput>
|
||||
</div>
|
||||
|
||||
{/* Handling notes */}
|
||||
@@ -312,34 +285,26 @@ export default function ShippingSettingsForm({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-zinc-300">
|
||||
🌽 Refrigerated / Perishable Notes
|
||||
</label>
|
||||
<textarea
|
||||
<AdminInput
|
||||
label="Refrigerated / Perishable Notes"
|
||||
helpText="Applied to all shipments with perishable items (is_perishable = true)."
|
||||
>
|
||||
<AdminTextarea
|
||||
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>
|
||||
</AdminInput>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-zinc-300">
|
||||
📦 Fragile Items Notes
|
||||
</label>
|
||||
<textarea
|
||||
<AdminInput label="Fragile Items Notes">
|
||||
<AdminTextarea
|
||||
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>
|
||||
</AdminInput>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { updateStop } from "@/actions/stops/update-stop";
|
||||
import { AdminInput, AdminTextInput, AdminSelect } from "./design-system";
|
||||
|
||||
type Brand = {
|
||||
id: string;
|
||||
@@ -94,113 +95,86 @@ export default function StopEditForm({ stop, brands }: StopEditFormProps) {
|
||||
)}
|
||||
|
||||
<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>
|
||||
<AdminInput label="City">
|
||||
<AdminTextInput
|
||||
value={city}
|
||||
onChange={(e) => setCity(e.target.value)}
|
||||
placeholder="City name"
|
||||
/>
|
||||
</AdminInput>
|
||||
|
||||
<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>
|
||||
<AdminInput label="State">
|
||||
<AdminTextInput
|
||||
value={state}
|
||||
onChange={(e) => setState(e.target.value)}
|
||||
placeholder="State code"
|
||||
/>
|
||||
</AdminInput>
|
||||
</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>
|
||||
<AdminInput label="Date">
|
||||
<AdminTextInput
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
/>
|
||||
</AdminInput>
|
||||
|
||||
<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>
|
||||
<AdminInput label="Time">
|
||||
<AdminTextInput
|
||||
value={time}
|
||||
onChange={(e) => setTime(e.target.value)}
|
||||
placeholder="e.g. 8:00 AM – 2:00 PM"
|
||||
/>
|
||||
</AdminInput>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-zinc-300">Location</label>
|
||||
<input
|
||||
type="text"
|
||||
<AdminInput label="Location">
|
||||
<AdminTextInput
|
||||
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>
|
||||
</AdminInput>
|
||||
|
||||
<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>
|
||||
<AdminInput label="Street Address">
|
||||
<AdminTextInput
|
||||
value={address}
|
||||
onChange={(e) => setAddress(e.target.value)}
|
||||
placeholder="123 Main St"
|
||||
/>
|
||||
</AdminInput>
|
||||
|
||||
<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>
|
||||
<AdminInput label="ZIP Code">
|
||||
<AdminTextInput
|
||||
value={zip}
|
||||
onChange={(e) => setZip(e.target.value)}
|
||||
placeholder="80102"
|
||||
maxLength={10}
|
||||
/>
|
||||
</AdminInput>
|
||||
</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>
|
||||
<AdminInput
|
||||
label="Order Cutoff"
|
||||
helpText="Customers must order before this time to be included at this stop."
|
||||
>
|
||||
<AdminTextInput
|
||||
type="datetime-local"
|
||||
value={cutoff_time}
|
||||
onChange={(e) => setCutoff_time(e.target.value)}
|
||||
/>
|
||||
</AdminInput>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-zinc-300">Brand</label>
|
||||
<select
|
||||
<AdminInput label="Brand">
|
||||
<AdminSelect
|
||||
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>
|
||||
options={brands.map((b) => ({ value: b.id, label: b.name }))}
|
||||
/>
|
||||
</AdminInput>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-zinc-300">Status</label>
|
||||
|
||||
@@ -4,6 +4,7 @@ import React, { useState, useTransition } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { publishStop } from "@/actions/stops";
|
||||
import { AdminSearchInput, AdminFilterTabs, AdminButton, AdminIconButton } from "@/components/admin/design-system";
|
||||
|
||||
type Stop = {
|
||||
id: string;
|
||||
@@ -45,6 +46,20 @@ export default function StopTableClient({ stops }: Props) {
|
||||
return matchesSearch && matchesStatus;
|
||||
});
|
||||
|
||||
const statusCounts = {
|
||||
all: stops.length,
|
||||
active: stops.filter(s => s.active && s.status !== "draft").length,
|
||||
inactive: stops.filter(s => !s.active && s.status !== "draft").length,
|
||||
draft: stops.filter(s => s.status === "draft").length,
|
||||
};
|
||||
|
||||
const tabs = [
|
||||
{ value: "all", label: "All", count: statusCounts.all },
|
||||
{ value: "active", label: "Active", count: statusCounts.active },
|
||||
{ value: "inactive", label: "Inactive", count: statusCounts.inactive },
|
||||
{ value: "draft", label: "Draft", count: statusCounts.draft },
|
||||
];
|
||||
|
||||
const totalPages = Math.ceil(filtered.length / PAGE_SIZE);
|
||||
const paginatedStops = filtered.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
|
||||
|
||||
@@ -58,48 +73,53 @@ export default function StopTableClient({ stops }: Props) {
|
||||
return (
|
||||
<>
|
||||
{/* Filter bar */}
|
||||
<div className="border-b border-stone-200 px-5 py-3 flex gap-3 flex-wrap items-center">
|
||||
<input
|
||||
type="search"
|
||||
<div className="border-b border-[var(--admin-border)] px-5 py-3 flex gap-4 flex-wrap items-center">
|
||||
<AdminSearchInput
|
||||
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"
|
||||
onClear={() => { setSearch(""); setPage(0); }}
|
||||
showClear={true}
|
||||
className="flex-1 min-w-48 max-w-64"
|
||||
/>
|
||||
<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>
|
||||
<AdminFilterTabs
|
||||
activeTab={statusFilter}
|
||||
onTabChange={(value) => { setStatusFilter(value as typeof statusFilter); setPage(0); }}
|
||||
tabs={tabs}
|
||||
size="sm"
|
||||
showCounts={true}
|
||||
/>
|
||||
<span className="text-xs text-[var(--admin-text-muted)] ml-auto">{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">
|
||||
<div className="flex items-center gap-1">
|
||||
<AdminIconButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
label="Previous page"
|
||||
onClick={() => setPage(p => Math.max(0, p - 1))}
|
||||
disabled={page === 0}
|
||||
className="!rounded-lg border border-[var(--admin-border)]"
|
||||
>
|
||||
<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">
|
||||
</AdminIconButton>
|
||||
<span className="text-xs text-[var(--admin-text-muted)] px-1">{page + 1}/{totalPages}</span>
|
||||
<AdminIconButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
label="Next page"
|
||||
onClick={() => setPage(p => Math.min(totalPages - 1, p + 1))}
|
||||
disabled={page >= totalPages - 1}
|
||||
className="!rounded-lg border border-[var(--admin-border)]"
|
||||
>
|
||||
<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>
|
||||
</AdminIconButton>
|
||||
</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">
|
||||
<div className="mx-5 my-3 rounded-lg border border-[var(--admin-danger)]/30 bg-[var(--admin-danger)]/10 px-4 py-3 text-sm text-[var(--admin-danger)]">
|
||||
{deleteError}{" "}
|
||||
<button onClick={() => setDeleteError(null)} className="underline hover:no-underline">
|
||||
Dismiss
|
||||
@@ -109,7 +129,7 @@ export default function StopTableClient({ stops }: Props) {
|
||||
|
||||
{/* Table */}
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="bg-stone-50 text-stone-500">
|
||||
<thead className="bg-[var(--admin-bg-subtle)] text-[var(--admin-text-muted)]">
|
||||
<tr>
|
||||
<th className="px-5 py-4 font-semibold">City</th>
|
||||
<th className="px-5 py-4 font-semibold">Location</th>
|
||||
@@ -120,10 +140,10 @@ export default function StopTableClient({ stops }: Props) {
|
||||
<th className="px-5 py-4 font-semibold" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-stone-100">
|
||||
<tbody className="divide-y divide-[var(--admin-border)]">
|
||||
{filtered.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-5 py-10 text-center text-sm text-stone-500">
|
||||
<td colSpan={7} className="px-5 py-10 text-center text-sm text-[var(--admin-text-muted)]">
|
||||
{search || statusFilter !== "all"
|
||||
? "No stops match your search."
|
||||
: "No stops found. Create one to get started."}
|
||||
@@ -196,23 +216,23 @@ function StopRowBase({
|
||||
}
|
||||
|
||||
return (
|
||||
<tr className="hover:bg-stone-50 transition-colors relative">
|
||||
<tr className="hover:bg-[var(--admin-bg-subtle)] 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"
|
||||
className="font-medium text-[var(--admin-text-primary)] hover:text-[var(--admin-accent)] 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 text-[var(--admin-text-secondary)]">{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-[var(--admin-text-muted)] 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 font-mono text-[var(--admin-text-muted)] text-xs">{stop.time}</td>
|
||||
|
||||
<td className="px-5 py-4 text-stone-600">
|
||||
<td className="px-5 py-4 text-[var(--admin-text-secondary)]">
|
||||
{Array.isArray(stop.brands)
|
||||
? stop.brands[0]?.name
|
||||
: stop.brands?.name}
|
||||
@@ -224,8 +244,8 @@ function StopRowBase({
|
||||
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"
|
||||
? "bg-[var(--admin-accent-light)] text-[var(--admin-accent-text)] border border-[var(--admin-accent)]"
|
||||
: "bg-[var(--admin-bg-subtle)] text-[var(--admin-text-muted)] border border-[var(--admin-border)]"
|
||||
}`}
|
||||
>
|
||||
{stop.status === "draft" ? "Draft" : stop.active ? "Active" : "Inactive"}
|
||||
@@ -236,19 +256,23 @@ function StopRowBase({
|
||||
<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"
|
||||
className="rounded-lg px-3 py-1.5 text-xs font-medium text-[var(--admin-text-secondary)] hover:text-[var(--admin-text-primary)] hover:bg-[var(--admin-bg-subtle)] transition-colors"
|
||||
>
|
||||
Edit
|
||||
</Link>
|
||||
<button
|
||||
<AdminIconButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
label="More options"
|
||||
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>
|
||||
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<circle cx="10" cy="4" r="1.5"/><circle cx="10" cy="10" r="1.5"/><circle cx="10" cy="16" r="1.5"/>
|
||||
</svg>
|
||||
</AdminIconButton>
|
||||
|
||||
{openMenu === stop.id && (
|
||||
<>
|
||||
@@ -256,28 +280,34 @@ function StopRowBase({
|
||||
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">
|
||||
<div className="absolute right-0 top-full mt-1 z-20 w-44 rounded-xl bg-white border border-[var(--admin-border)] shadow-xl overflow-hidden">
|
||||
{stop.status === "draft" && (
|
||||
<button
|
||||
<AdminButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
fullWidth
|
||||
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"
|
||||
className="!justify-start !text-[var(--admin-accent)] hover:!bg-[var(--admin-accent-light)]"
|
||||
>
|
||||
{publishingId === stop.id ? "Publishing..." : "Publish"}
|
||||
</button>
|
||||
</AdminButton>
|
||||
)}
|
||||
<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"
|
||||
className="flex items-center gap-2 px-4 py-2.5 text-sm text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] transition-colors"
|
||||
>
|
||||
Duplicate
|
||||
</a>
|
||||
<button
|
||||
<AdminButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
fullWidth
|
||||
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"
|
||||
className="!justify-start !text-[var(--admin-danger)] hover:!bg-[var(--admin-danger)]/10"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</AdminButton>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -288,27 +318,30 @@ function StopRowBase({
|
||||
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">
|
||||
<div className="absolute right-0 top-full mt-1 z-40 w-72 rounded-xl bg-white border border-[var(--admin-border)] shadow-xl p-4">
|
||||
<p className="text-sm font-semibold text-[var(--admin-text-primary)]">
|
||||
Delete "{stop.city}, {stop.state}"?
|
||||
</p>
|
||||
<p className="mt-1.5 text-xs text-stone-500">
|
||||
<p className="mt-1.5 text-xs text-[var(--admin-text-muted)]">
|
||||
This will remove the stop. If it has active orders, you must resolve those first.
|
||||
</p>
|
||||
<div className="mt-4 flex gap-2">
|
||||
<button
|
||||
<AdminButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
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
|
||||
</AdminButton>
|
||||
<AdminButton
|
||||
variant="danger"
|
||||
size="sm"
|
||||
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"
|
||||
isLoading={deletingId === stop.id}
|
||||
>
|
||||
{deletingId === stop.id ? "..." : "Delete"}
|
||||
</button>
|
||||
</AdminButton>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState } from "react";
|
||||
import ScheduleImportModal from "@/components/admin/ScheduleImportModal";
|
||||
import AddStopModal from "@/components/admin/AddStopModal";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { AdminButton } from "@/components/admin/design-system";
|
||||
|
||||
type Props = {
|
||||
brandId: string;
|
||||
@@ -25,24 +26,28 @@ export default function StopsHeaderActions({ brandId }: Props) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
<AdminButton
|
||||
variant="secondary"
|
||||
onClick={() => setShowImport(true)}
|
||||
className="flex items-center gap-2 rounded-xl border border-stone-300 bg-white px-4 py-2.5 text-sm font-medium text-stone-700 hover:bg-stone-50 hover:border-stone-400 transition-colors"
|
||||
icon={
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
|
||||
</svg>
|
||||
}
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
|
||||
</svg>
|
||||
Upload Schedule
|
||||
</button>
|
||||
<button
|
||||
</AdminButton>
|
||||
<AdminButton
|
||||
variant="primary"
|
||||
onClick={() => setShowAdd(true)}
|
||||
className="flex items-center gap-2 rounded-xl bg-emerald-600 hover:bg-emerald-500 active:bg-emerald-700 px-5 py-2.5 text-sm font-semibold text-white transition-colors shadow-sm shadow-emerald-200"
|
||||
icon={
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
}
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Add Stop
|
||||
</button>
|
||||
</AdminButton>
|
||||
</div>
|
||||
|
||||
{showImport && (
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type TaxOrderRow,
|
||||
type TaxByStateRow,
|
||||
} from "@/lib/reports-export";
|
||||
import { AdminButton, AdminFilterTabs } from "@/components/admin/design-system";
|
||||
|
||||
type DatePreset = "month" | "quarter" | "this_year" | "custom";
|
||||
|
||||
@@ -80,12 +81,13 @@ function SummaryCard({
|
||||
|
||||
function ExportBtn({ label, onClick }: { label: string; onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
<AdminButton
|
||||
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"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
>
|
||||
Export CSV
|
||||
</button>
|
||||
</AdminButton>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -123,8 +125,8 @@ function ReportTable({ headers, rows, renderRow }: {
|
||||
}
|
||||
|
||||
const TABS = [
|
||||
{ id: "summary", label: "Tax Summary" },
|
||||
{ id: "orders", label: "Taxable Orders" },
|
||||
{ value: "summary", label: "Tax Summary" },
|
||||
{ value: "orders", label: "Taxable Orders" },
|
||||
];
|
||||
|
||||
export default function TaxDashboard({
|
||||
@@ -279,20 +281,17 @@ export default function TaxDashboard({
|
||||
)}
|
||||
|
||||
{/* ── 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 flex-wrap items-center gap-3 rounded-xl border border-[var(--admin-border)] bg-[var(--admin-card-bg)] px-5 py-4">
|
||||
<div className="flex gap-1">
|
||||
{(["month", "quarter", "this_year", "custom"] as DatePreset[]).map((p) => (
|
||||
<button
|
||||
<AdminButton
|
||||
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"
|
||||
}`}
|
||||
variant={preset === p ? "primary" : "secondary"}
|
||||
size="sm"
|
||||
>
|
||||
{p === "month" ? "This Month" : p === "quarter" ? "This Quarter" : p === "this_year" ? "This Year" : "Custom"}
|
||||
</button>
|
||||
</AdminButton>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -331,13 +330,15 @@ export default function TaxDashboard({
|
||||
</select>
|
||||
)}
|
||||
|
||||
<button
|
||||
<AdminButton
|
||||
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"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
isLoading={loading}
|
||||
>
|
||||
{loading ? "Loading..." : "Refresh"}
|
||||
</button>
|
||||
Refresh
|
||||
</AdminButton>
|
||||
|
||||
<span className="ml-auto text-xs text-zinc-500">
|
||||
{preset === "quarter" ? quarterLabel(range.start, range.end) :
|
||||
@@ -347,21 +348,13 @@ export default function TaxDashboard({
|
||||
</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>
|
||||
<AdminFilterTabs
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
tabs={TABS}
|
||||
size="md"
|
||||
showCounts={false}
|
||||
/>
|
||||
|
||||
{/* ── TAX SUMMARY TAB ── */}
|
||||
{activeTab === "summary" && (
|
||||
|
||||
@@ -58,9 +58,9 @@ export default function TaxQuarterlySummary({ brandId }: { brandId: string }) {
|
||||
|
||||
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 className="rounded-xl border border-[var(--admin-border)] bg-[var(--admin-card-bg)] px-4 py-3 flex items-center gap-2">
|
||||
<div className="h-4 w-4 rounded-full border-2 border-[var(--admin-accent)] border-t-transparent animate-spin" />
|
||||
<span className="text-xs text-[var(--admin-text-secondary)]">Loading tax summary...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -72,25 +72,25 @@ export default function TaxQuarterlySummary({ brandId }: { brandId: string }) {
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3">
|
||||
<div className="rounded-xl border border-[var(--admin-border)] bg-[var(--admin-card-bg)] 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">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-[var(--admin-text-secondary)]">
|
||||
{quarterLabel()} Tax Collected
|
||||
</span>
|
||||
</div>
|
||||
<Link
|
||||
href="/admin/taxes"
|
||||
className="text-xs text-amber-600 hover:text-amber-800 font-medium"
|
||||
className="text-xs text-[var(--admin-accent-text)] hover:text-[var(--admin-accent)] font-medium transition-colors"
|
||||
>
|
||||
View Details →
|
||||
</Link>
|
||||
</div>
|
||||
<div className="mt-2 flex items-baseline gap-4">
|
||||
<span className="text-xl font-bold text-amber-800">
|
||||
<span className="text-xl font-bold text-[var(--admin-text-primary)]">
|
||||
${data.total_tax_collected.toLocaleString(undefined, { minimumFractionDigits: 2 })}
|
||||
</span>
|
||||
<span className="text-xs text-amber-600">
|
||||
<span className="text-xs text-[var(--admin-text-muted)]">
|
||||
on ${data.total_gross_sales.toLocaleString(undefined, { minimumFractionDigits: 2 })} gross sales · {effectiveRate.toFixed(3)}% rate · {data.order_count} orders
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
} from "@/actions/time-tracking";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import TimeTrackingSettingsClient from "./TimeTrackingSettingsClient";
|
||||
import { AdminButton, AdminFilterTabs } from "./design-system";
|
||||
|
||||
// One-color outline icons
|
||||
const Icons = {
|
||||
@@ -112,7 +113,7 @@ const PAGE_SIZE = 50;
|
||||
function StatusBadge({ active }: { active: boolean }) {
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-bold ${
|
||||
active ? "bg-green-50 text-green-700" : "bg-stone-100 text-stone-500"
|
||||
active ? "bg-green-100 text-green-700" : "bg-stone-100 text-stone-500"
|
||||
}`}>
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${active ? "bg-green-500" : "bg-stone-400"}`} />
|
||||
{active ? "Active" : "Inactive"}
|
||||
@@ -135,12 +136,12 @@ function formatMinutes(minutes: number): string {
|
||||
|
||||
type Tab = "summary" | "workers" | "tasks" | "logs" | "settings";
|
||||
|
||||
const TABS: { id: Tab; label: string; description: string }[] = [
|
||||
{ id: "summary", label: "Summary", description: "Overview & stats" },
|
||||
{ id: "workers", label: "Workers", description: "Manage team" },
|
||||
{ id: "tasks", label: "Tasks", description: "Job types" },
|
||||
{ id: "logs", label: "Logs", description: "Time entries" },
|
||||
{ id: "settings", label: "Settings", description: "Configuration" },
|
||||
const TABS = [
|
||||
{ value: "summary", label: "Summary", icon: Icons.chart("h-4 w-4") },
|
||||
{ value: "workers", label: "Workers", icon: Icons.user("h-4 w-4") },
|
||||
{ value: "tasks", label: "Tasks", icon: Icons.clipboard("h-4 w-4") },
|
||||
{ value: "logs", label: "Logs", icon: Icons.clock("h-4 w-4") },
|
||||
{ value: "settings", label: "Settings", icon: Icons.settings("h-4 w-4") },
|
||||
];
|
||||
|
||||
export default function TimeTrackingAdminPanel({ brandId }: { brandId?: string }) {
|
||||
@@ -193,7 +194,7 @@ export default function TimeTrackingAdminPanel({ brandId }: { brandId?: string }
|
||||
|
||||
if (!brandId) {
|
||||
return (
|
||||
<div className="text-center py-20 text-stone-500">
|
||||
<div className="text-center py-20 text-[var(--admin-text-muted)]">
|
||||
<p className="text-sm">Select a brand to view time tracking.</p>
|
||||
</div>
|
||||
);
|
||||
@@ -205,7 +206,7 @@ export default function TimeTrackingAdminPanel({ brandId }: { brandId?: string }
|
||||
<div className="px-4 sm:px-6 md:px-8 pt-4 sm:pt-6 pb-2">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 sm:h-12 sm:w-12 items-center justify-center rounded-xl bg-emerald-600">
|
||||
<div className="flex h-10 w-10 sm:h-12 sm:w-12 items-center justify-center rounded-xl bg-[var(--admin-accent)]">
|
||||
{Icons.clock("h-5 w-5 sm:h-6 sm:w-6 text-white")}
|
||||
</div>
|
||||
<div>
|
||||
@@ -213,40 +214,23 @@ export default function TimeTrackingAdminPanel({ brandId }: { brandId?: string }
|
||||
<p className="text-xs text-[var(--admin-text-muted)]">{dateRange.start} — {dateRange.end}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
<AdminButton
|
||||
onClick={() => setExportModal(true)}
|
||||
className="inline-flex items-center gap-2 rounded-xl border border-stone-200 bg-white px-4 py-2 text-sm font-semibold text-stone-600 hover:bg-stone-50 shadow-sm transition-colors"
|
||||
icon={Icons.download("h-4 w-4")}
|
||||
variant="secondary"
|
||||
>
|
||||
{Icons.download("h-4 w-4")}
|
||||
<span>Export</span>
|
||||
</button>
|
||||
Export
|
||||
</AdminButton>
|
||||
</div>
|
||||
|
||||
{/* Tab navigation - Route Trace style */}
|
||||
<nav className="grid grid-cols-5 gap-1 p-1.5 rounded-xl bg-white border border-stone-200">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => setTab(t.id)}
|
||||
className={`relative flex flex-col sm:flex-row items-center justify-center gap-1 sm:gap-2 rounded-lg px-2 sm:px-4 py-2.5 sm:py-3 text-[10px] sm:text-sm font-semibold transition-colors ${
|
||||
tab === t.id
|
||||
? "bg-emerald-600 text-white"
|
||||
: "text-stone-500 hover:text-stone-700 hover:bg-stone-50"
|
||||
}`}
|
||||
>
|
||||
{t.id === "summary" && Icons.chart("h-4 w-4")}
|
||||
{t.id === "workers" && Icons.user("h-4 w-4")}
|
||||
{t.id === "tasks" && Icons.clipboard("h-4 w-4")}
|
||||
{t.id === "logs" && Icons.clock("h-4 w-4")}
|
||||
{t.id === "settings" && Icons.settings("h-4 w-4")}
|
||||
<span className="hidden sm:inline">{t.label}</span>
|
||||
<span className="sm:hidden">{t.label.substring(0, 3)}</span>
|
||||
{tab === t.id && (
|
||||
<div className="absolute bottom-1 sm:bottom-1.5 left-1/2 -translate-x-1/2 h-0.5 w-6 sm:w-12 bg-white rounded-full" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
{/* Tab navigation - using AdminFilterTabs */}
|
||||
<AdminFilterTabs
|
||||
activeTab={tab}
|
||||
onTabChange={(t) => setTab(t as Tab)}
|
||||
tabs={TABS}
|
||||
size="md"
|
||||
showCounts={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
@@ -356,17 +340,17 @@ export default function TimeTrackingAdminPanel({ brandId }: { brandId?: string }
|
||||
{exportModal && (
|
||||
<GlassModal title="Export Time Logs" onClose={() => setExportModal(false)}>
|
||||
<div className="p-6">
|
||||
<p className="text-sm text-stone-600 mb-4">Download time logs for the selected date range.</p>
|
||||
<button
|
||||
<p className="text-sm text-[var(--admin-text-secondary)] mb-4">Download time logs for the selected date range.</p>
|
||||
<AdminButton
|
||||
onClick={() => {
|
||||
window.location.href = `/api/time-tracking/export?brandId=${brandId}&start=${dateRange.start}&end=${dateRange.end}`;
|
||||
setExportModal(false);
|
||||
}}
|
||||
className="w-full rounded-xl bg-[var(--admin-accent)] py-3 text-sm font-bold text-white hover:opacity-90 flex items-center justify-center gap-2"
|
||||
icon={Icons.download("h-4 w-4")}
|
||||
fullWidth
|
||||
>
|
||||
{Icons.download("h-4 w-4")}
|
||||
Download CSV
|
||||
</button>
|
||||
</AdminButton>
|
||||
</div>
|
||||
</GlassModal>
|
||||
)}
|
||||
@@ -377,28 +361,28 @@ export default function TimeTrackingAdminPanel({ brandId }: { brandId?: string }
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-xs text-stone-500 uppercase tracking-wider mb-1">Worker</p>
|
||||
<p className="text-sm font-semibold text-stone-900">{logModal.worker_name}</p>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] uppercase tracking-wider mb-1">Worker</p>
|
||||
<p className="text-sm font-semibold text-[var(--admin-text-primary)]">{logModal.worker_name}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-stone-500 uppercase tracking-wider mb-1">Task</p>
|
||||
<p className="text-sm font-semibold text-stone-900">{logModal.task_name || "—"}</p>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] uppercase tracking-wider mb-1">Task</p>
|
||||
<p className="text-sm font-semibold text-[var(--admin-text-primary)]">{logModal.task_name || "—"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-stone-500 uppercase tracking-wider mb-1">Clock In</p>
|
||||
<p className="text-sm font-semibold text-stone-900">{logModal.clock_in ? formatDate(logModal.clock_in) : "—"}</p>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] uppercase tracking-wider mb-1">Clock In</p>
|
||||
<p className="text-sm font-semibold text-[var(--admin-text-primary)]">{logModal.clock_in ? formatDate(logModal.clock_in) : "—"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-stone-500 uppercase tracking-wider mb-1">Clock Out</p>
|
||||
<p className="text-sm font-semibold text-stone-900">{logModal.clock_out ? formatDate(logModal.clock_out) : "Open"}</p>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] uppercase tracking-wider mb-1">Clock Out</p>
|
||||
<p className="text-sm font-semibold text-[var(--admin-text-primary)]">{logModal.clock_out ? formatDate(logModal.clock_out) : "Open"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-stone-500 uppercase tracking-wider mb-1">Duration</p>
|
||||
<p className="text-sm font-semibold text-stone-900">{formatMinutes(logModal.total_minutes)}</p>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] uppercase tracking-wider mb-1">Duration</p>
|
||||
<p className="text-sm font-semibold text-[var(--admin-text-primary)]">{formatMinutes(logModal.total_minutes)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-stone-500 uppercase tracking-wider mb-1">Notes</p>
|
||||
<p className="text-sm text-stone-600">{logModal.notes || "—"}</p>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] uppercase tracking-wider mb-1">Notes</p>
|
||||
<p className="text-sm text-[var(--admin-text-secondary)]">{logModal.notes || "—"}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -429,7 +413,7 @@ function SummaryTab({ summary, workers, loading, dateRange, setDateRange, onRefr
|
||||
type="date"
|
||||
value={dateRange.start}
|
||||
onChange={(e) => setDateRange((prev) => ({ ...prev, start: e.target.value }))}
|
||||
className="px-3 py-2 rounded-lg border border-[var(--admin-border)] bg-stone-50 text-stone-900 text-sm focus:outline-none focus:border-emerald-600"
|
||||
className="px-3 py-2 rounded-lg border border-[var(--admin-border)] bg-white text-[var(--admin-text-primary)] text-sm focus:outline-none focus:border-[var(--admin-accent)]"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
@@ -438,16 +422,12 @@ function SummaryTab({ summary, workers, loading, dateRange, setDateRange, onRefr
|
||||
type="date"
|
||||
value={dateRange.end}
|
||||
onChange={(e) => setDateRange((prev) => ({ ...prev, end: e.target.value }))}
|
||||
className="px-3 py-2 rounded-lg border border-[var(--admin-border)] bg-stone-50 text-stone-900 text-sm focus:outline-none focus:border-emerald-600"
|
||||
className="px-3 py-2 rounded-lg border border-[var(--admin-border)] bg-white text-[var(--admin-text-primary)] text-sm focus:outline-none focus:border-[var(--admin-accent)]"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
className="px-4 py-2 rounded-lg bg-emerald-600 hover:bg-emerald-700 text-white text-sm font-semibold transition-colors flex items-center gap-2"
|
||||
>
|
||||
{Icons.refresh("h-4 w-4")}
|
||||
<span>Refresh</span>
|
||||
</button>
|
||||
<AdminButton onClick={onRefresh} icon={Icons.refresh("h-4 w-4")}>
|
||||
Refresh
|
||||
</AdminButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -502,12 +482,12 @@ function SummaryTab({ summary, workers, loading, dateRange, setDateRange, onRefr
|
||||
|
||||
{/* By Worker Table */}
|
||||
<div className="rounded-2xl border border-[var(--admin-border)] bg-white overflow-hidden">
|
||||
<div className="px-5 py-4 border-b border-[var(--admin-border)] bg-stone-50/40">
|
||||
<div className="px-5 py-4 border-b border-[var(--admin-border)] bg-[var(--admin-bg-subtle)]">
|
||||
<h3 className="text-sm font-bold text-[var(--admin-text-primary)]">By Worker</h3>
|
||||
</div>
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-[var(--admin-border)] bg-stone-50/50">
|
||||
<tr className="border-b border-[var(--admin-border)] bg-[var(--admin-bg-subtle)]">
|
||||
<th className="px-5 py-3 text-left text-xs font-bold text-[var(--admin-text-muted)] uppercase">Worker</th>
|
||||
<th className="px-5 py-3 text-right text-xs font-bold text-[var(--admin-text-muted)] uppercase">Entries</th>
|
||||
<th className="px-5 py-3 text-right text-xs font-bold text-[var(--admin-text-muted)] uppercase">Total Hours</th>
|
||||
@@ -518,7 +498,7 @@ function SummaryTab({ summary, workers, loading, dateRange, setDateRange, onRefr
|
||||
<tr><td colSpan={3} className="px-5 py-8 text-center text-[var(--admin-text-muted)]">No data</td></tr>
|
||||
) : (
|
||||
(summary.by_worker ?? []).map((w) => (
|
||||
<tr key={w.id} className="border-b border-stone-50 last:border-0 hover:bg-stone-50/40 transition-colors">
|
||||
<tr key={w.id} className="border-b border-[var(--admin-border)] last:border-0 hover:bg-[var(--admin-bg-subtle)] transition-colors">
|
||||
<td className="px-5 py-4 text-sm font-semibold text-[var(--admin-text-primary)]">{w.name}</td>
|
||||
<td className="px-5 py-4 text-sm text-[var(--admin-text-secondary)] text-right">{w.entry_count}</td>
|
||||
<td className="px-5 py-4 text-sm font-semibold text-[var(--admin-text-primary)] text-right">{formatMinutes(w.total_hours * 60)}</td>
|
||||
@@ -549,19 +529,15 @@ function WorkersTab({ workers, onAdd, onEdit, brandId, onSave }: {
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-[var(--admin-border)] bg-white overflow-hidden">
|
||||
<div className="px-5 py-4 border-b border-[var(--admin-border)] flex items-center justify-between bg-stone-50/40">
|
||||
<div className="px-5 py-4 border-b border-[var(--admin-border)] flex items-center justify-between bg-[var(--admin-bg-subtle)]">
|
||||
<h3 className="text-sm font-bold text-[var(--admin-text-primary)]">Workers ({workers.length})</h3>
|
||||
<button
|
||||
onClick={onAdd}
|
||||
className="inline-flex items-center gap-2 rounded-xl bg-emerald-600 px-4 py-2 text-sm font-bold text-white hover:bg-emerald-700 transition-colors"
|
||||
>
|
||||
{Icons.plus("h-4 w-4")}
|
||||
<span>Add Worker</span>
|
||||
</button>
|
||||
<AdminButton onClick={onAdd} icon={Icons.plus("h-4 w-4")}>
|
||||
Add Worker
|
||||
</AdminButton>
|
||||
</div>
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-[var(--admin-border)] bg-stone-50/50">
|
||||
<tr className="border-b border-[var(--admin-border)] bg-[var(--admin-bg-subtle)]">
|
||||
<th className="px-5 py-3 text-left text-xs font-bold text-[var(--admin-text-muted)] uppercase">Name</th>
|
||||
<th className="px-5 py-3 text-left text-xs font-bold text-[var(--admin-text-muted)] uppercase">PIN</th>
|
||||
<th className="px-5 py-3 text-left text-xs font-bold text-[var(--admin-text-muted)] uppercase">Status</th>
|
||||
@@ -574,7 +550,7 @@ function WorkersTab({ workers, onAdd, onEdit, brandId, onSave }: {
|
||||
<tr><td colSpan={5} className="px-5 py-8 text-center text-[var(--admin-text-muted)]">No workers yet</td></tr>
|
||||
) : (
|
||||
workers.map((w) => (
|
||||
<tr key={w.id} className="border-b border-stone-50 last:border-0 hover:bg-stone-50/40 transition-colors">
|
||||
<tr key={w.id} className="border-b border-[var(--admin-border)] last:border-0 hover:bg-[var(--admin-bg-subtle)] transition-colors">
|
||||
<td className="px-5 py-4 text-sm font-semibold text-[var(--admin-text-primary)]">{w.name}</td>
|
||||
<td className="px-5 py-4 text-sm font-mono text-[var(--admin-text-secondary)]">{w.pin || "—"}</td>
|
||||
<td className="px-5 py-4"><StatusBadge active={w.active} /></td>
|
||||
@@ -604,19 +580,15 @@ function TasksTab({ tasks, onAdd, onEdit, brandId, onSave }: {
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-[var(--admin-border)] bg-white overflow-hidden">
|
||||
<div className="px-5 py-4 border-b border-[var(--admin-border)] flex items-center justify-between bg-stone-50/40">
|
||||
<div className="px-5 py-4 border-b border-[var(--admin-border)] flex items-center justify-between bg-[var(--admin-bg-subtle)]">
|
||||
<h3 className="text-sm font-bold text-[var(--admin-text-primary)]">Tasks ({tasks.length})</h3>
|
||||
<button
|
||||
onClick={onAdd}
|
||||
className="inline-flex items-center gap-2 rounded-xl bg-emerald-600 px-4 py-2 text-sm font-bold text-white hover:bg-emerald-700 transition-colors"
|
||||
>
|
||||
{Icons.plus("h-4 w-4")}
|
||||
<span>Add Task</span>
|
||||
</button>
|
||||
<AdminButton onClick={onAdd} icon={Icons.plus("h-4 w-4")}>
|
||||
Add Task
|
||||
</AdminButton>
|
||||
</div>
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-[var(--admin-border)] bg-stone-50/50">
|
||||
<tr className="border-b border-[var(--admin-border)] bg-[var(--admin-bg-subtle)]">
|
||||
<th className="px-5 py-3 text-left text-xs font-bold text-[var(--admin-text-muted)] uppercase">Name</th>
|
||||
<th className="px-5 py-3 text-left text-xs font-bold text-[var(--admin-text-muted)] uppercase">Unit</th>
|
||||
<th className="px-5 py-3 text-left text-xs font-bold text-[var(--admin-text-muted)] uppercase">Status</th>
|
||||
@@ -628,7 +600,7 @@ function TasksTab({ tasks, onAdd, onEdit, brandId, onSave }: {
|
||||
<tr><td colSpan={4} className="px-5 py-8 text-center text-[var(--admin-text-muted)]">No tasks yet</td></tr>
|
||||
) : (
|
||||
tasks.map((t) => (
|
||||
<tr key={t.id} className="border-b border-stone-50 last:border-0 hover:bg-stone-50/40 transition-colors">
|
||||
<tr key={t.id} className="border-b border-[var(--admin-border)] last:border-0 hover:bg-[var(--admin-bg-subtle)] transition-colors">
|
||||
<td className="px-5 py-4">
|
||||
<div className="text-sm font-semibold text-[var(--admin-text-primary)]">{t.name}</div>
|
||||
{t.name_es && <div className="text-xs text-[var(--admin-text-muted)]">{t.name_es}</div>}
|
||||
@@ -676,7 +648,7 @@ function LogsTab({ logs, workers, tasks, dateRange, setDateRange, selectedWorker
|
||||
type="date"
|
||||
value={dateRange.start}
|
||||
onChange={(e) => setDateRange((prev) => ({ ...prev, start: e.target.value }))}
|
||||
className="px-3 py-2 rounded-lg border border-[var(--admin-border)] bg-stone-50 text-stone-900 text-sm focus:outline-none focus:border-emerald-600"
|
||||
className="px-3 py-2 rounded-lg border border-[var(--admin-border)] bg-white text-[var(--admin-text-primary)] text-sm focus:outline-none focus:border-[var(--admin-accent)]"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
@@ -685,7 +657,7 @@ function LogsTab({ logs, workers, tasks, dateRange, setDateRange, selectedWorker
|
||||
type="date"
|
||||
value={dateRange.end}
|
||||
onChange={(e) => setDateRange((prev) => ({ ...prev, end: e.target.value }))}
|
||||
className="px-3 py-2 rounded-lg border border-[var(--admin-border)] bg-stone-50 text-stone-900 text-sm focus:outline-none focus:border-emerald-600"
|
||||
className="px-3 py-2 rounded-lg border border-[var(--admin-border)] bg-white text-[var(--admin-text-primary)] text-sm focus:outline-none focus:border-[var(--admin-accent)]"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
@@ -693,7 +665,7 @@ function LogsTab({ logs, workers, tasks, dateRange, setDateRange, selectedWorker
|
||||
<select
|
||||
value={selectedWorker}
|
||||
onChange={(e) => setSelectedWorker(e.target.value)}
|
||||
className="px-3 py-2 rounded-lg border border-[var(--admin-border)] bg-stone-50 text-stone-900 text-sm focus:outline-none focus:border-emerald-600 min-w-[160px]"
|
||||
className="px-3 py-2 rounded-lg border border-[var(--admin-border)] bg-white text-[var(--admin-text-primary)] text-sm focus:outline-none focus:border-[var(--admin-accent)] min-w-[160px]"
|
||||
>
|
||||
<option value="">All Workers</option>
|
||||
{workers.map((w) => (
|
||||
@@ -706,7 +678,7 @@ function LogsTab({ logs, workers, tasks, dateRange, setDateRange, selectedWorker
|
||||
<select
|
||||
value={selectedTask}
|
||||
onChange={(e) => setSelectedTask(e.target.value)}
|
||||
className="px-3 py-2 rounded-lg border border-[var(--admin-border)] bg-stone-50 text-stone-900 text-sm focus:outline-none focus:border-emerald-600 min-w-[160px]"
|
||||
className="px-3 py-2 rounded-lg border border-[var(--admin-border)] bg-white text-[var(--admin-text-primary)] text-sm focus:outline-none focus:border-[var(--admin-accent)] min-w-[160px]"
|
||||
>
|
||||
<option value="">All Tasks</option>
|
||||
{tasks.map((t) => (
|
||||
@@ -714,13 +686,9 @@ function LogsTab({ logs, workers, tasks, dateRange, setDateRange, selectedWorker
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
className="px-4 py-2 rounded-lg bg-emerald-600 hover:bg-emerald-700 text-white text-sm font-semibold transition-colors flex items-center gap-2"
|
||||
>
|
||||
{Icons.refresh("h-4 w-4")}
|
||||
<span>Refresh</span>
|
||||
</button>
|
||||
<AdminButton onClick={onRefresh} icon={Icons.refresh("h-4 w-4")}>
|
||||
Refresh
|
||||
</AdminButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -737,12 +705,12 @@ function LogsTab({ logs, workers, tasks, dateRange, setDateRange, selectedWorker
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-2xl border border-[var(--admin-border)] bg-white overflow-hidden">
|
||||
<div className="px-5 py-4 border-b border-[var(--admin-border)] bg-stone-50/40">
|
||||
<div className="px-5 py-4 border-b border-[var(--admin-border)] bg-[var(--admin-bg-subtle)]">
|
||||
<h3 className="text-sm font-bold text-[var(--admin-text-primary)]">Time Logs</h3>
|
||||
</div>
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-[var(--admin-border)] bg-stone-50/50">
|
||||
<tr className="border-b border-[var(--admin-border)] bg-[var(--admin-bg-subtle)]">
|
||||
<th className="px-5 py-3 text-left text-xs font-bold text-[var(--admin-text-muted)] uppercase">Worker</th>
|
||||
<th className="px-5 py-3 text-left text-xs font-bold text-[var(--admin-text-muted)] uppercase">Task</th>
|
||||
<th className="px-5 py-3 text-left text-xs font-bold text-[var(--admin-text-muted)] uppercase">Clock In</th>
|
||||
@@ -755,7 +723,7 @@ function LogsTab({ logs, workers, tasks, dateRange, setDateRange, selectedWorker
|
||||
<tr><td colSpan={5} className="px-5 py-8 text-center text-[var(--admin-text-muted)]">No logs yet</td></tr>
|
||||
) : (
|
||||
logs.map((log) => (
|
||||
<tr key={log.id} className="border-b border-stone-50 last:border-0 hover:bg-stone-50/40 transition-colors cursor-pointer" onClick={() => onViewLog(log)}>
|
||||
<tr key={log.id} className="border-b border-[var(--admin-border)] last:border-0 hover:bg-[var(--admin-bg-subtle)] transition-colors cursor-pointer" onClick={() => onViewLog(log)}>
|
||||
<td className="px-5 py-4 text-sm font-semibold text-[var(--admin-text-primary)]">{log.worker_name}</td>
|
||||
<td className="px-5 py-4 text-sm text-[var(--admin-text-secondary)]">{log.task_name || "—"}</td>
|
||||
<td className="px-5 py-4 text-sm text-[var(--admin-text-muted)]">{log.clock_in ? formatDate(log.clock_in) : "—"}</td>
|
||||
@@ -822,42 +790,42 @@ function WorkerModal({ worker, brandId, onClose, onSave }: {
|
||||
<GlassModal title={isEdit ? "Edit Worker" : "Add Worker"} onClose={onClose}>
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-stone-700 mb-1">Name *</label>
|
||||
<input value={name} onChange={(e) => setName(e.target.value)} required className="w-full rounded-lg border border-[var(--admin-border)] px-3 py-2.5 text-sm focus:outline-none focus:border-emerald-600" />
|
||||
<label className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Name *</label>
|
||||
<input value={name} onChange={(e) => setName(e.target.value)} required className="w-full rounded-lg border border-[var(--admin-border)] px-3 py-2.5 text-sm focus:outline-none focus:border-[var(--admin-accent)]" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-stone-700 mb-1">Role</label>
|
||||
<select value={role} onChange={(e) => setRole(e.target.value)} className="w-full rounded-lg border border-[var(--admin-border)] px-3 py-2.5 text-sm focus:outline-none focus:border-emerald-600">
|
||||
<label className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Role</label>
|
||||
<select value={role} onChange={(e) => setRole(e.target.value)} className="w-full rounded-lg border border-[var(--admin-border)] px-3 py-2.5 text-sm focus:outline-none focus:border-[var(--admin-accent)]">
|
||||
<option value="worker">Worker</option>
|
||||
<option value="supervisor">Supervisor</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-stone-700 mb-1">Language</label>
|
||||
<select value={lang} onChange={(e) => setLang(e.target.value)} className="w-full rounded-lg border border-[var(--admin-border)] px-3 py-2.5 text-sm focus:outline-none focus:border-emerald-600">
|
||||
<label className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Language</label>
|
||||
<select value={lang} onChange={(e) => setLang(e.target.value)} className="w-full rounded-lg border border-[var(--admin-border)] px-3 py-2.5 text-sm focus:outline-none focus:border-[var(--admin-accent)]">
|
||||
<option value="en">English</option>
|
||||
<option value="es">Español</option>
|
||||
</select>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" checked={active} onChange={(e) => setActive(e.target.checked)} className="rounded border-[var(--admin-border)]" />
|
||||
<span className="text-sm text-stone-700">Active</span>
|
||||
<span className="text-sm text-[var(--admin-text-secondary)]">Active</span>
|
||||
</label>
|
||||
<div className="flex gap-2 pt-2">
|
||||
{isEdit && worker && (
|
||||
<>
|
||||
<button type="button" onClick={handleResetPin} disabled={loading} className="rounded-lg border border-[var(--admin-border)] px-3 py-2 text-xs font-semibold text-[var(--admin-text-secondary)] hover:bg-stone-50 disabled:opacity-50 flex items-center gap-1">
|
||||
{Icons.refresh("h-3.5 w-3.5")} Reset PIN
|
||||
</button>
|
||||
<button type="button" onClick={handleDelete} disabled={loading} className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs font-semibold text-red-600 hover:bg-red-100 ml-auto flex items-center gap-1">
|
||||
{Icons.trash("h-3.5 w-3.5")} Delete
|
||||
</button>
|
||||
<AdminButton type="button" variant="secondary" size="sm" onClick={handleResetPin} disabled={loading} icon={Icons.refresh("h-3.5 w-3.5")}>
|
||||
Reset PIN
|
||||
</AdminButton>
|
||||
<AdminButton type="button" variant="danger" size="sm" onClick={handleDelete} disabled={loading} icon={Icons.trash("h-3.5 w-3.5")}>
|
||||
Delete
|
||||
</AdminButton>
|
||||
</>
|
||||
)}
|
||||
<button type="submit" disabled={loading} className="rounded-lg bg-[var(--admin-accent)] px-4 py-2.5 text-sm font-bold text-white hover:opacity-90 disabled:opacity-50 ml-auto">
|
||||
<AdminButton type="submit" disabled={loading} isLoading={loading} className="ml-auto">
|
||||
{loading ? "Saving..." : isEdit ? "Update" : "Create"}
|
||||
</button>
|
||||
</AdminButton>
|
||||
</div>
|
||||
</form>
|
||||
</GlassModal>
|
||||
@@ -907,16 +875,16 @@ function TaskModal({ task, brandId, onClose, onSave }: {
|
||||
<GlassModal title={isEdit ? "Edit Task" : "Add Task"} onClose={onClose}>
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-stone-700 mb-1">Task Name *</label>
|
||||
<input value={name} onChange={(e) => setName(e.target.value)} required placeholder="e.g., Harvesting" className="w-full rounded-lg border border-[var(--admin-border)] px-3 py-2.5 text-sm focus:outline-none focus:border-emerald-600" />
|
||||
<label className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Task Name *</label>
|
||||
<input value={name} onChange={(e) => setName(e.target.value)} required placeholder="e.g., Harvesting" className="w-full rounded-lg border border-[var(--admin-border)] px-3 py-2.5 text-sm focus:outline-none focus:border-[var(--admin-accent)]" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-stone-700 mb-1">Name in Spanish</label>
|
||||
<input value={nameEs} onChange={(e) => setNameEs(e.target.value)} placeholder="e.g., Cosecha" className="w-full rounded-lg border border-[var(--admin-border)] px-3 py-2.5 text-sm focus:outline-none focus:border-emerald-600" />
|
||||
<label className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Name in Spanish</label>
|
||||
<input value={nameEs} onChange={(e) => setNameEs(e.target.value)} placeholder="e.g., Cosecha" className="w-full rounded-lg border border-[var(--admin-border)] px-3 py-2.5 text-sm focus:outline-none focus:border-[var(--admin-accent)]" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-stone-700 mb-1">Unit</label>
|
||||
<select value={unit} onChange={(e) => setUnit(e.target.value)} className="w-full rounded-lg border border-[var(--admin-border)] px-3 py-2.5 text-sm focus:outline-none focus:border-emerald-600">
|
||||
<label className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Unit</label>
|
||||
<select value={unit} onChange={(e) => setUnit(e.target.value)} className="w-full rounded-lg border border-[var(--admin-border)] px-3 py-2.5 text-sm focus:outline-none focus:border-[var(--admin-accent)]">
|
||||
<option value="hours">Hours</option>
|
||||
<option value="pieces">Pieces</option>
|
||||
<option value="units">Units</option>
|
||||
@@ -924,17 +892,17 @@ function TaskModal({ task, brandId, onClose, onSave }: {
|
||||
</div>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" checked={active} onChange={(e) => setActive(e.target.checked)} className="rounded border-[var(--admin-border)]" />
|
||||
<span className="text-sm text-stone-700">Active</span>
|
||||
<span className="text-sm text-[var(--admin-text-secondary)]">Active</span>
|
||||
</label>
|
||||
<div className="flex gap-2 pt-2">
|
||||
{isEdit && task && (
|
||||
<button type="button" onClick={handleDelete} disabled={loading} className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs font-semibold text-red-600 hover:bg-red-100 flex items-center gap-1">
|
||||
{Icons.trash("h-3.5 w-3.5")} Delete
|
||||
</button>
|
||||
<AdminButton type="button" variant="danger" size="sm" onClick={handleDelete} disabled={loading} icon={Icons.trash("h-3.5 w-3.5")}>
|
||||
Delete
|
||||
</AdminButton>
|
||||
)}
|
||||
<button type="submit" disabled={loading} className="rounded-lg bg-[var(--admin-accent)] px-4 py-2.5 text-sm font-bold text-white hover:opacity-90 disabled:opacity-50 ml-auto">
|
||||
<AdminButton type="submit" disabled={loading} isLoading={loading} className="ml-auto">
|
||||
{loading ? "Saving..." : isEdit ? "Update" : "Create"}
|
||||
</button>
|
||||
</AdminButton>
|
||||
</div>
|
||||
</form>
|
||||
</GlassModal>
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
type TimeTrackingSettings,
|
||||
type NotificationLogEntry,
|
||||
} from "@/actions/time-tracking";
|
||||
import { AdminButton } from "@/components/admin/design-system";
|
||||
|
||||
// One-color outline icons
|
||||
const Icons = {
|
||||
@@ -306,107 +307,87 @@ export default function TimeTrackingSettingsClient({ brandId }: TimeTrackingSett
|
||||
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" },
|
||||
end_of_period_summary:{ en: "Period Summary", color: "bg-green-100 text-green-700" },
|
||||
};
|
||||
|
||||
if (loading) return <div className="text-center py-16 text-stone-500 text-sm">Loading...</div>;
|
||||
if (loading) return <div className="text-center py-16 text-[var(--admin-text-muted)] 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 className="bg-white border border-[var(--admin-border)] rounded-xl p-5 text-center">
|
||||
<p className="text-3xl font-bold text-[var(--admin-text-primary)]">{workers.length}</p>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] 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 className="bg-white border border-[var(--admin-border)] rounded-xl p-5 text-center">
|
||||
<p className="text-3xl font-bold text-[var(--admin-text-primary)]">{tasks.length}</p>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] uppercase tracking-widest mt-1">Tasks</p>
|
||||
</div>
|
||||
<div className="bg-white border border-stone-200 rounded-xl p-5 text-center shadow-sm">
|
||||
<div className="bg-white border border-[var(--admin-border)] rounded-xl p-5 text-center">
|
||||
<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>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] 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="bg-white border border-[var(--admin-border)] rounded-xl p-6 space-y-4">
|
||||
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">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-stone-400">{Icons.clock("h-5 w-5")}</span>
|
||||
className="flex items-center gap-3 px-4 py-3 rounded-xl bg-[var(--admin-bg-subtle)] border border-[var(--admin-border)] hover:border-[var(--admin-accent)] transition-all text-left">
|
||||
<span className="text-[var(--admin-text-muted)]">{Icons.clock("h-5 w-5")}</span>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-stone-800">QuickBooks</p>
|
||||
<p className="text-xs text-stone-500">Time import CSV</p>
|
||||
<p className="text-sm font-semibold text-[var(--admin-text-primary)]">QuickBooks</p>
|
||||
<p className="text-xs text-[var(--admin-text-muted)]">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-stone-400">{Icons.dollarSign("h-5 w-5")}</span>
|
||||
className="flex items-center gap-3 px-4 py-3 rounded-xl bg-[var(--admin-bg-subtle)] border border-[var(--admin-border)] hover:border-blue-500 transition-all text-left">
|
||||
<span className="text-[var(--admin-text-muted)]">{Icons.dollarSign("h-5 w-5")}</span>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-stone-800">Payroll</p>
|
||||
<p className="text-xs text-stone-500">ADP / Gusto / Generic</p>
|
||||
<p className="text-sm font-semibold text-[var(--admin-text-primary)]">Payroll</p>
|
||||
<p className="text-xs text-[var(--admin-text-muted)]">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-stone-400">{Icons.clipboard("h-5 w-5")}</span>
|
||||
className="flex items-center gap-3 px-4 py-3 rounded-xl bg-[var(--admin-bg-subtle)] border border-[var(--admin-border)] hover:border-violet-500 transition-all text-left">
|
||||
<span className="text-[var(--admin-text-muted)]">{Icons.clipboard("h-5 w-5")}</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>
|
||||
<p className="text-sm font-semibold text-[var(--admin-text-primary)]">Detailed Log</p>
|
||||
<p className="text-xs text-[var(--admin-text-muted)]">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-stone-400">{Icons.chart("h-5 w-5")}</span>
|
||||
className="flex items-center gap-3 px-4 py-3 rounded-xl bg-[var(--admin-bg-subtle)] border border-[var(--admin-border)] hover:border-amber-500 transition-all text-left">
|
||||
<span className="text-[var(--admin-text-muted)]">{Icons.chart("h-5 w-5")}</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>
|
||||
<p className="text-sm font-semibold text-[var(--admin-text-primary)]">Period Summary</p>
|
||||
<p className="text-xs text-[var(--admin-text-muted)]">Per-worker totals</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs text-stone-500">
|
||||
<div className="flex items-center gap-4 text-xs text-[var(--admin-text-muted)]">
|
||||
<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" />
|
||||
className="px-3 py-1.5 rounded-lg border border-[var(--admin-border)] bg-white text-[var(--admin-text-primary)] focus:outline-none focus:border-[var(--admin-accent)]" />
|
||||
<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" />
|
||||
className="px-3 py-1.5 rounded-lg border border-[var(--admin-border)] bg-white text-[var(--admin-text-primary)] focus:outline-none focus:border-[var(--admin-accent)]" />
|
||||
</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 className="bg-white border border-[var(--admin-border)] rounded-xl overflow-hidden">
|
||||
<div className="px-5 py-4 border-b border-[var(--admin-border)] flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">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">
|
||||
<tr className="text-xs text-[var(--admin-text-muted)] uppercase tracking-widest border-b border-[var(--admin-border)]">
|
||||
<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>
|
||||
@@ -416,21 +397,21 @@ export default function TimeTrackingSettingsClient({ brandId }: TimeTrackingSett
|
||||
</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>
|
||||
<tr><td colSpan={5} className="px-5 py-8 text-center text-[var(--admin-text-muted)]">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>
|
||||
<tr key={entry.id} className="border-t border-[var(--admin-border)] hover:bg-[var(--admin-bg-subtle)] transition-colors">
|
||||
<td className="px-5 py-3.5 text-[var(--admin-text-muted)] text-xs">{new Date(entry.created_at).toLocaleString()}</td>
|
||||
<td className="px-5 py-3.5 text-[var(--admin-text-primary)] 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-emerald-600">{Icons.check("h-4 w-4")}</span>
|
||||
<span className="text-[var(--admin-accent)]">{Icons.check("h-4 w-4")}</span>
|
||||
</td>
|
||||
<td className="px-5 py-3.5 text-center">
|
||||
<span className="text-stone-300">{entry.sms_sent ? Icons.check("h-4 w-4 text-emerald-600") : "—"}</span>
|
||||
<span className="text-[var(--admin-text-muted)]">{entry.sms_sent ? Icons.check("h-4 w-4 text-[var(--admin-accent)]") : "—"}</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
@@ -445,15 +426,16 @@ export default function TimeTrackingSettingsClient({ brandId }: TimeTrackingSett
|
||||
{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 className="bg-white border border-[var(--admin-border)] rounded-xl overflow-hidden">
|
||||
<div className="px-5 py-4 border-b border-[var(--admin-border)] flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">Workers & PINs</h3>
|
||||
<AdminButton onClick={openAddWorker} size="sm">
|
||||
+ Add Worker
|
||||
</AdminButton>
|
||||
</div>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-xs text-stone-500 uppercase tracking-widest border-b border-stone-100">
|
||||
<tr className="text-xs text-[var(--admin-text-muted)] uppercase tracking-widest border-b border-[var(--admin-border)]">
|
||||
<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>
|
||||
@@ -464,23 +446,23 @@ export default function TimeTrackingSettingsClient({ brandId }: TimeTrackingSett
|
||||
</thead>
|
||||
<tbody>
|
||||
{workers.length === 0 ? (
|
||||
<tr><td colSpan={6} className="px-5 py-12 text-center text-stone-400">No workers yet</td></tr>
|
||||
<tr><td colSpan={6} className="px-5 py-12 text-center text-[var(--admin-text-muted)]">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>
|
||||
<tr key={w.id} className="border-t border-[var(--admin-border)] hover:bg-[var(--admin-bg-subtle)] transition-colors">
|
||||
<td className="px-5 py-3.5 text-[var(--admin-text-primary)] font-medium">{w.name}</td>
|
||||
<td className="px-5 py-3.5 text-[var(--admin-text-muted)] capitalize">{w.role}</td>
|
||||
<td className="px-5 py-3.5 text-[var(--admin-text-muted)] 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"}`}>
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold ${w.active ? "bg-green-100 text-green-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-[var(--admin-text-muted)] 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>
|
||||
<AdminButton variant="ghost" size="sm" onClick={() => openEditWorker(w)}>Edit</AdminButton>
|
||||
<AdminButton variant="ghost" size="sm" onClick={() => handleResetPin(w.id)}>Reset PIN</AdminButton>
|
||||
<AdminButton variant="danger" size="sm" onClick={() => handleDeleteWorker(w.id)}>Delete</AdminButton>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -490,15 +472,16 @@ export default function TimeTrackingSettingsClient({ brandId }: TimeTrackingSett
|
||||
</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 className="bg-white border border-[var(--admin-border)] rounded-xl overflow-hidden">
|
||||
<div className="px-5 py-4 border-b border-[var(--admin-border)] flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">Tasks</h3>
|
||||
<AdminButton onClick={openAddTask} size="sm">
|
||||
+ Add Task
|
||||
</AdminButton>
|
||||
</div>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-xs text-stone-500 uppercase tracking-widest border-b border-stone-100">
|
||||
<tr className="text-xs text-[var(--admin-text-muted)] uppercase tracking-widest border-b border-[var(--admin-border)]">
|
||||
<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>
|
||||
@@ -509,22 +492,22 @@ export default function TimeTrackingSettingsClient({ brandId }: TimeTrackingSett
|
||||
</thead>
|
||||
<tbody>
|
||||
{tasks.length === 0 ? (
|
||||
<tr><td colSpan={6} className="px-5 py-12 text-center text-stone-400">No tasks yet</td></tr>
|
||||
<tr><td colSpan={6} className="px-5 py-12 text-center text-[var(--admin-text-muted)]">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>
|
||||
<tr key={t.id} className="border-t border-[var(--admin-border)] hover:bg-[var(--admin-bg-subtle)] transition-colors">
|
||||
<td className="px-5 py-3.5 text-[var(--admin-text-primary)] font-medium">{t.name}</td>
|
||||
<td className="px-5 py-3.5 text-[var(--admin-text-muted)]">{t.name_es ?? "—"}</td>
|
||||
<td className="px-5 py-3.5 text-[var(--admin-text-muted)] text-xs font-mono">{t.unit}</td>
|
||||
<td className="px-5 py-3.5 text-[var(--admin-text-muted)] 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"}`}>
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold ${t.active ? "bg-green-100 text-green-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>
|
||||
<AdminButton variant="ghost" size="sm" onClick={() => openEditTask(t)}>Edit</AdminButton>
|
||||
<AdminButton variant="danger" size="sm" onClick={() => handleDeleteTask(t.id)}>Delete</AdminButton>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -534,41 +517,41 @@ export default function TimeTrackingSettingsClient({ brandId }: TimeTrackingSett
|
||||
</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="bg-white border border-[var(--admin-border)] rounded-xl p-6 space-y-5">
|
||||
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">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>
|
||||
<label className="block text-xs font-semibold uppercase tracking-widest text-[var(--admin-text-muted)] 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">
|
||||
className="w-full px-4 py-3 rounded-xl border border-[var(--admin-border)] bg-white text-[var(--admin-text-primary)] focus:outline-none focus:border-[var(--admin-accent)] 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>
|
||||
<label className="block text-xs font-semibold uppercase tracking-widest text-[var(--admin-text-muted)] 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>
|
||||
className="flex-1 px-4 py-3 rounded-xl border border-[var(--admin-border)] bg-white text-[var(--admin-text-primary)] focus:outline-none focus:border-[var(--admin-accent)] transition-colors" />
|
||||
<span className="text-sm text-[var(--admin-text-muted)]">days</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-2">Daily overtime threshold</label>
|
||||
<label className="block text-xs font-semibold uppercase tracking-widest text-[var(--admin-text-muted)] 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>
|
||||
className="flex-1 px-4 py-3 rounded-xl border border-[var(--admin-border)] bg-white text-[var(--admin-text-primary)] focus:outline-none focus:border-[var(--admin-accent)] transition-colors" />
|
||||
<span className="text-sm text-[var(--admin-text-muted)]">hrs</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-2">Weekly overtime threshold</label>
|
||||
<label className="block text-xs font-semibold uppercase tracking-widest text-[var(--admin-text-muted)] 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>
|
||||
className="flex-1 px-4 py-3 rounded-xl border border-[var(--admin-border)] bg-white text-[var(--admin-text-primary)] focus:outline-none focus:border-[var(--admin-accent)] transition-colors" />
|
||||
<span className="text-sm text-[var(--admin-text-muted)]">hrs</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -586,36 +569,36 @@ export default function TimeTrackingSettingsClient({ brandId }: TimeTrackingSett
|
||||
</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="bg-white border border-[var(--admin-border)] rounded-xl p-6 space-y-5">
|
||||
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">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>
|
||||
<p className="text-sm text-[var(--admin-text-secondary)] font-medium">Daily overtime alerts</p>
|
||||
<p className="text-xs text-[var(--admin-text-muted)]">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"}`}>
|
||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${enableDailyAlerts ? "bg-[var(--admin-accent)]" : "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>
|
||||
<p className="text-sm text-[var(--admin-text-secondary)] font-medium">Weekly overtime alerts</p>
|
||||
<p className="text-xs text-[var(--admin-text-muted)]">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"}`}>
|
||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${enableWeeklyAlerts ? "bg-[var(--admin-accent)]" : "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>
|
||||
<p className="text-sm text-[var(--admin-text-secondary)] font-medium">Show overtime warning on employee screen</p>
|
||||
<p className="text-xs text-[var(--admin-text-muted)]">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"}`}>
|
||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${overtimeNotifications ? "bg-[var(--admin-accent)]" : "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>
|
||||
@@ -623,42 +606,42 @@ export default function TimeTrackingSettingsClient({ brandId }: TimeTrackingSett
|
||||
</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 className="bg-white border border-[var(--admin-border)] rounded-xl p-6 space-y-4">
|
||||
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">Notification Recipients</h3>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-2">Email addresses</label>
|
||||
<label className="block text-xs font-semibold uppercase tracking-widest text-[var(--admin-text-muted)] 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"
|
||||
className="flex-1 px-4 py-3 rounded-xl border border-[var(--admin-border)] bg-white text-[var(--admin-text-primary)] placeholder:text-[var(--admin-text-muted)] focus:outline-none focus:border-[var(--admin-accent)] 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>
|
||||
<AdminButton onClick={addEmail}>Add</AdminButton>
|
||||
</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">
|
||||
<span key={e} className="inline-flex items-center gap-1 bg-[var(--admin-bg-subtle)] text-[var(--admin-text-secondary)] text-xs px-2 py-1 rounded-lg">
|
||||
{e}
|
||||
<button onClick={() => removeEmail(e)} className="text-stone-400 hover:text-red-500 ml-1">×</button>
|
||||
<button onClick={() => removeEmail(e)} className="text-[var(--admin-text-muted)] 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>
|
||||
<label className="block text-xs font-semibold uppercase tracking-widest text-[var(--admin-text-muted)] 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"
|
||||
className="flex-1 px-4 py-3 rounded-xl border border-[var(--admin-border)] bg-white text-[var(--admin-text-primary)] placeholder:text-[var(--admin-text-muted)] focus:outline-none focus:border-[var(--admin-accent)] 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>
|
||||
<AdminButton onClick={addSms}>Add</AdminButton>
|
||||
</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">
|
||||
<span key={n} className="inline-flex items-center gap-1 bg-[var(--admin-bg-subtle)] text-[var(--admin-text-secondary)] text-xs px-2 py-1 rounded-lg">
|
||||
{n}
|
||||
<button onClick={() => removeSms(n)} className="text-stone-400 hover:text-red-500 ml-1">×</button>
|
||||
<button onClick={() => removeSms(n)} className="text-[var(--admin-text-muted)] hover:text-red-500 ml-1">×</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
@@ -669,51 +652,50 @@ export default function TimeTrackingSettingsClient({ brandId }: TimeTrackingSett
|
||||
<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>
|
||||
<div className="bg-green-50 border border-green-200 rounded-xl py-3 px-4 text-green-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>
|
||||
<AdminButton onClick={handleSaveNotifications} isLoading={settingsSaving}>
|
||||
Save Settings
|
||||
</AdminButton>
|
||||
</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 className="bg-white border border-[var(--admin-border)] rounded-2xl w-full max-w-md shadow-xl">
|
||||
<div className="px-6 py-4 border-b border-[var(--admin-border)] flex items-center justify-between">
|
||||
<h3 className="text-lg font-bold text-[var(--admin-text-primary)]">{editingWorker ? "Edit Worker" : "Add Worker"}</h3>
|
||||
<button onClick={() => setShowWorkerModal(false)} className="text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)] 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 className="bg-green-50 border border-green-200 rounded-xl py-3 px-4">
|
||||
<p className="text-green-700 text-sm font-semibold mb-1">New PIN:</p>
|
||||
<p className="text-2xl font-mono font-bold text-[var(--admin-text-primary)]">{resetPinResult}</p>
|
||||
<p className="text-[var(--admin-text-muted)] 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>
|
||||
<label className="block text-xs font-semibold uppercase tracking-widest text-[var(--admin-text-muted)] 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"
|
||||
className="w-full px-4 py-3 rounded-xl border border-[var(--admin-border)] bg-white text-[var(--admin-text-primary)] placeholder:text-[var(--admin-text-muted)] focus:outline-none focus:border-[var(--admin-accent)] 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>
|
||||
<label className="block text-xs font-semibold uppercase tracking-widest text-[var(--admin-text-muted)] 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">
|
||||
className="w-full px-4 py-3 rounded-xl border border-[var(--admin-border)] bg-white text-[var(--admin-text-primary)] focus:outline-none focus:border-[var(--admin-accent)] 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>
|
||||
<label className="block text-xs font-semibold uppercase tracking-widest text-[var(--admin-text-muted)] 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">
|
||||
className="w-full px-4 py-3 rounded-xl border border-[var(--admin-border)] bg-white text-[var(--admin-text-primary)] focus:outline-none focus:border-[var(--admin-accent)] transition-colors">
|
||||
<option value="en">English</option>
|
||||
<option value="es">Español</option>
|
||||
</select>
|
||||
@@ -721,25 +703,23 @@ export default function TimeTrackingSettingsClient({ brandId }: TimeTrackingSett
|
||||
</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>
|
||||
className="w-4 h-4 rounded border-[var(--admin-border)] bg-white text-[var(--admin-accent)]" />
|
||||
<label htmlFor="workerActive" className="text-sm text-[var(--admin-text-secondary)]">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">
|
||||
<div className="pt-2 border-t border-[var(--admin-border)]">
|
||||
<AdminButton variant="secondary" size="sm" onClick={() => handleResetPin(editingWorker.id)}>
|
||||
Reset PIN for this worker
|
||||
</button>
|
||||
</AdminButton>
|
||||
</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">
|
||||
<AdminButton type="button" variant="secondary" onClick={() => setShowWorkerModal(false)} fullWidth>
|
||||
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">
|
||||
</AdminButton>
|
||||
<AdminButton type="button" onClick={handleSaveWorker} isLoading={submitting} fullWidth>
|
||||
{submitting ? "..." : editingWorker ? "Save" : "Add Worker"}
|
||||
</button>
|
||||
</AdminButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -749,55 +729,53 @@ export default function TimeTrackingSettingsClient({ brandId }: TimeTrackingSett
|
||||
{/* 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 className="bg-white border border-[var(--admin-border)] rounded-2xl w-full max-w-md shadow-xl">
|
||||
<div className="px-6 py-4 border-b border-[var(--admin-border)] flex items-center justify-between">
|
||||
<h3 className="text-lg font-bold text-[var(--admin-text-primary)]">{editingTask ? "Edit Task" : "Add Task"}</h3>
|
||||
<button onClick={() => setShowTaskModal(false)} className="text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)] 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>
|
||||
<label className="block text-xs font-semibold uppercase tracking-widest text-[var(--admin-text-muted)] 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"
|
||||
className="w-full px-4 py-3 rounded-xl border border-[var(--admin-border)] bg-white text-[var(--admin-text-primary)] placeholder:text-[var(--admin-text-muted)] focus:outline-none focus:border-[var(--admin-accent)] 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>
|
||||
<label className="block text-xs font-semibold uppercase tracking-widest text-[var(--admin-text-muted)] 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"
|
||||
className="w-full px-4 py-3 rounded-xl border border-[var(--admin-border)] bg-white text-[var(--admin-text-primary)] placeholder:text-[var(--admin-text-muted)] focus:outline-none focus:border-[var(--admin-accent)] 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>
|
||||
<label className="block text-xs font-semibold uppercase tracking-widest text-[var(--admin-text-muted)] 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">
|
||||
className="w-full px-4 py-3 rounded-xl border border-[var(--admin-border)] bg-white text-[var(--admin-text-primary)] focus:outline-none focus:border-[var(--admin-accent)] 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>
|
||||
<label className="block text-xs font-semibold uppercase tracking-widest text-[var(--admin-text-muted)] 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" />
|
||||
className="w-full px-4 py-3 rounded-xl border border-[var(--admin-border)] bg-white text-[var(--admin-text-primary)] focus:outline-none focus:border-[var(--admin-accent)] 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>
|
||||
className="w-4 h-4 rounded border-[var(--admin-border)] bg-white text-[var(--admin-accent)]" />
|
||||
<label htmlFor="taskActive" className="text-sm text-[var(--admin-text-secondary)]">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">
|
||||
<AdminButton type="button" variant="secondary" onClick={() => setShowTaskModal(false)} fullWidth>
|
||||
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">
|
||||
</AdminButton>
|
||||
<AdminButton type="button" onClick={handleSaveTask} isLoading={submitting} fullWidth>
|
||||
{submitting ? "..." : editingTask ? "Save" : "Add Task"}
|
||||
</button>
|
||||
</AdminButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -300,7 +300,11 @@ export default function UsersPage({ initialUsers, brands, currentUser }: UsersPa
|
||||
{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>
|
||||
<button onClick={() => setError(null)} className="text-red-400 hover:text-red-600 shrink-0">
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { updateWaterEntry, deleteWaterEntry } from "@/actions/water-log/admin";
|
||||
import { AdminInput, AdminTextInput, AdminTextarea, AdminSelect, AdminButton } from "./design-system";
|
||||
|
||||
type WaterEntry = {
|
||||
id: string;
|
||||
@@ -63,53 +64,45 @@ export default function WaterLogEntryEditForm({ entry, backHref = "/admin/water-
|
||||
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">
|
||||
<div className="rounded-lg bg-red-100 border border-red-200 p-3 text-sm text-red-700">
|
||||
{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>
|
||||
<label className="block text-sm font-medium text-[var(--admin-text-muted)] mb-1">Headgate</label>
|
||||
<p className="rounded-lg border border-[var(--admin-border)] bg-[var(--admin-bg-subtle)] px-3 py-2 text-sm text-[var(--admin-text-secondary)]">{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>
|
||||
<label className="block text-sm font-medium text-[var(--admin-text-muted)] mb-1">User</label>
|
||||
<p className="rounded-lg border border-[var(--admin-border)] bg-[var(--admin-bg-subtle)] px-3 py-2 text-sm text-[var(--admin-text-secondary)]">{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
|
||||
<AdminInput label="Measurement" required>
|
||||
<AdminTextInput
|
||||
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
|
||||
</AdminInput>
|
||||
<AdminInput label="Unit">
|
||||
<AdminSelect
|
||||
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>
|
||||
options={UNIT_OPTIONS.map((u) => ({ value: u, label: u }))}
|
||||
/>
|
||||
</AdminInput>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--admin-text-muted)] mb-1">Submitted Via</label>
|
||||
<p className="rounded-lg border border-[var(--admin-border)] bg-[var(--admin-bg-subtle)] px-3 py-2 text-sm text-[var(--admin-text-secondary)]">{entry.submitted_via}</p>
|
||||
</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">
|
||||
<label className="block text-sm font-medium text-[var(--admin-text-muted)] mb-1">Logged</label>
|
||||
<p className="rounded-lg border border-[var(--admin-border)] bg-[var(--admin-bg-subtle)] px-3 py-2 text-sm text-[var(--admin-text-secondary)]">
|
||||
{new Date(entry.logged_at).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
@@ -120,33 +113,32 @@ export default function WaterLogEntryEditForm({ entry, backHref = "/admin/water-
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-400 mb-1">Notes</label>
|
||||
<textarea
|
||||
<AdminInput label="Notes" className="col-span-2">
|
||||
<AdminTextarea
|
||||
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>
|
||||
</AdminInput>
|
||||
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<button
|
||||
<AdminButton
|
||||
type="button"
|
||||
variant="danger"
|
||||
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"
|
||||
isLoading={deleting}
|
||||
>
|
||||
{deleting ? "..." : "Delete Entry"}
|
||||
</button>
|
||||
<button
|
||||
Delete Entry
|
||||
</AdminButton>
|
||||
<AdminButton
|
||||
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"
|
||||
isLoading={saving}
|
||||
>
|
||||
{saving ? "..." : "Save Changes"}
|
||||
</button>
|
||||
Save Changes
|
||||
</AdminButton>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { updateWaterIrrigator, resetWaterIrrigatorPin, deleteWaterUser } from "@/actions/water-log/admin";
|
||||
import { AdminButton } from "./design-system";
|
||||
|
||||
type WaterUser = {
|
||||
id: string;
|
||||
@@ -70,53 +71,53 @@ export default function WaterUserEditForm({ waterUser, backHref = "/admin/water-
|
||||
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">
|
||||
<div className="rounded-lg bg-red-100 border border-red-200 p-3 text-sm text-red-700">
|
||||
{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 className="rounded-xl bg-amber-100 border border-amber-200 p-4">
|
||||
<p className="font-semibold text-amber-800">New PIN for {name}:</p>
|
||||
<p className="mt-1 text-2xl font-bold tracking-widest text-amber-900">{newPin}</p>
|
||||
<p className="mt-1 text-xs text-amber-600">Write this down — it will not be shown again.</p>
|
||||
<button onClick={() => setNewPin(null)} className="mt-2 text-xs text-amber-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>
|
||||
<label className="block text-sm font-medium text-[var(--admin-text-muted)] 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"
|
||||
className="w-full rounded-lg border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-400 mb-1">Role</label>
|
||||
<label className="block text-sm font-medium text-[var(--admin-text-muted)] 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"
|
||||
className="w-full rounded-lg border border-[var(--admin-border)] 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">
|
||||
<p className="text-xs text-[var(--admin-text-muted)] 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>
|
||||
<label className="block text-sm font-medium text-[var(--admin-text-muted)] 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"
|
||||
className="w-full rounded-lg border border-[var(--admin-border)] px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="en">English</option>
|
||||
<option value="es">Español</option>
|
||||
@@ -126,44 +127,46 @@ export default function WaterUserEditForm({ waterUser, backHref = "/admin/water-
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-400 mb-1">Status</label>
|
||||
<label className="block text-sm font-medium text-[var(--admin-text-muted)] 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"
|
||||
className="rounded-lg border border-[var(--admin-border)] px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="1">Active</option>
|
||||
<option value="0">Inactive</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="pt-4">
|
||||
<button
|
||||
<AdminButton
|
||||
type="button"
|
||||
variant="secondary"
|
||||
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"
|
||||
isLoading={resetting}
|
||||
>
|
||||
{resetting ? "..." : "Reset PIN"}
|
||||
</button>
|
||||
Reset PIN
|
||||
</AdminButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<button
|
||||
<AdminButton
|
||||
type="button"
|
||||
variant="danger"
|
||||
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"
|
||||
isLoading={deleting}
|
||||
>
|
||||
{deleting ? "..." : "Delete User"}
|
||||
</button>
|
||||
<button
|
||||
Delete User
|
||||
</AdminButton>
|
||||
<AdminButton
|
||||
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"
|
||||
isLoading={saving}
|
||||
>
|
||||
{saving ? "..." : "Save Changes"}
|
||||
</button>
|
||||
Save Changes
|
||||
</AdminButton>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -16,7 +16,7 @@ type AdminActionMenuProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function AdminActionMenu({ actions, triggerLabel = "⋮", className = "" }: AdminActionMenuProps) {
|
||||
export default function AdminActionMenu({ actions, triggerLabel, className = "" }: AdminActionMenuProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -41,7 +41,9 @@ export default function AdminActionMenu({ actions, triggerLabel = "⋮", classNa
|
||||
onClick={() => setOpen(!open)}
|
||||
className="rounded-lg px-2 py-1.5 text-xs text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] transition-colors"
|
||||
>
|
||||
{triggerLabel}
|
||||
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<circle cx="10" cy="4" r="1.5"/><circle cx="10" cy="10" r="1.5"/><circle cx="10" cy="16" r="1.5"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode, ButtonHTMLAttributes } from "react";
|
||||
|
||||
type ButtonVariant = "primary" | "secondary" | "danger" | "ghost";
|
||||
type ButtonSize = "sm" | "md" | "lg";
|
||||
|
||||
type AdminButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
icon?: ReactNode;
|
||||
iconPosition?: "left" | "right";
|
||||
isLoading?: boolean;
|
||||
fullWidth?: boolean;
|
||||
};
|
||||
|
||||
const variantClasses: Record<ButtonVariant, string> = {
|
||||
primary: `
|
||||
bg-[var(--admin-accent)] text-white border border-transparent
|
||||
hover:bg-[var(--admin-accent-hover)]
|
||||
active:bg-[var(--admin-accent-hover)]
|
||||
`,
|
||||
secondary: `
|
||||
bg-[var(--admin-card-bg)] text-[var(--admin-text-secondary)] border border-[var(--admin-border)]
|
||||
hover:bg-[var(--admin-bg)] hover:border-[var(--admin-text-muted)]
|
||||
active:bg-[var(--admin-bg)]
|
||||
`,
|
||||
danger: `
|
||||
bg-[var(--admin-danger)] text-white border border-transparent
|
||||
hover:bg-[var(--admin-danger-hover)]
|
||||
active:bg-[var(--admin-danger-hover)]
|
||||
`,
|
||||
ghost: `
|
||||
bg-transparent text-[var(--admin-text-secondary)] border border-transparent
|
||||
hover:bg-[var(--admin-bg-subtle)] hover:text-[var(--admin-text-primary)]
|
||||
active:bg-[var(--admin-bg-subtle)]
|
||||
`,
|
||||
};
|
||||
|
||||
const sizeClasses: Record<ButtonSize, string> = {
|
||||
sm: "px-3 py-1.5 text-xs rounded-lg gap-1.5",
|
||||
md: "px-4 py-2 text-sm rounded-xl gap-2",
|
||||
lg: "px-5 py-3 text-base rounded-xl gap-2",
|
||||
};
|
||||
|
||||
export default function AdminButton({
|
||||
children,
|
||||
variant = "primary",
|
||||
size = "md",
|
||||
icon,
|
||||
iconPosition = "left",
|
||||
isLoading = false,
|
||||
fullWidth = false,
|
||||
className = "",
|
||||
disabled,
|
||||
...props
|
||||
}: AdminButtonProps) {
|
||||
const baseClasses = `
|
||||
inline-flex items-center justify-center font-semibold
|
||||
transition-all duration-200 cursor-pointer
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2
|
||||
`;
|
||||
|
||||
const variantClass = variantClasses[variant];
|
||||
const sizeClass = sizeClasses[size];
|
||||
const widthClass = fullWidth ? "w-full" : "";
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{isLoading && (
|
||||
<svg className="animate-spin h-4 w-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 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
)}
|
||||
{!isLoading && icon && iconPosition === "left" && (
|
||||
<span className="flex-shrink-0">{icon}</span>
|
||||
)}
|
||||
{children && <span>{children}</span>}
|
||||
{!isLoading && icon && iconPosition === "right" && (
|
||||
<span className="flex-shrink-0">{icon}</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`${baseClasses} ${variantClass} ${sizeClass} ${widthClass} ${className}`}
|
||||
disabled={disabled || isLoading}
|
||||
{...props}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// Icon Button variant for icon-only buttons
|
||||
type AdminIconButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
variant?: "primary" | "secondary" | "danger" | "ghost";
|
||||
size?: "sm" | "md" | "lg";
|
||||
label: string; // For accessibility
|
||||
};
|
||||
|
||||
const iconSizeClasses: Record<ButtonSize, string> = {
|
||||
sm: "p-1.5",
|
||||
md: "p-2",
|
||||
lg: "p-2.5",
|
||||
};
|
||||
|
||||
export function AdminIconButton({
|
||||
variant = "ghost",
|
||||
size = "md",
|
||||
label,
|
||||
className = "",
|
||||
...props
|
||||
}: AdminIconButtonProps) {
|
||||
const baseClasses = `
|
||||
inline-flex items-center justify-center rounded-lg
|
||||
transition-all duration-150 cursor-pointer
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
`;
|
||||
|
||||
const variantClass = variantClasses[variant];
|
||||
const sizeClass = iconSizeClasses[size];
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`${baseClasses} ${variantClass} ${sizeClass} ${className}`}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
"use client";
|
||||
|
||||
type TabItem = {
|
||||
/** Unique value for the tab */
|
||||
value: string;
|
||||
/** Display label for the tab */
|
||||
label: string;
|
||||
/** Optional count badge */
|
||||
count?: number;
|
||||
/** Optional icon element */
|
||||
icon?: React.ReactNode;
|
||||
/** Disable this tab */
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
type AdminFilterTabsProps = {
|
||||
/** Currently active tab value */
|
||||
activeTab: string;
|
||||
/** Callback when tab changes */
|
||||
onTabChange: (value: string) => void;
|
||||
/** Array of tab items */
|
||||
tabs: TabItem[];
|
||||
/** CSS class for the tabs container */
|
||||
className?: string;
|
||||
/** Size variant */
|
||||
size?: "sm" | "md";
|
||||
/** Show count badges */
|
||||
showCounts?: boolean;
|
||||
};
|
||||
|
||||
const sizeClasses = {
|
||||
sm: {
|
||||
container: "p-0.5 gap-0.5",
|
||||
tab: "px-2.5 py-1.5 text-[10px]",
|
||||
},
|
||||
md: {
|
||||
container: "p-1 gap-0.5",
|
||||
tab: "px-3 py-1.5 text-xs",
|
||||
},
|
||||
};
|
||||
|
||||
export default function AdminFilterTabs({
|
||||
activeTab,
|
||||
onTabChange,
|
||||
tabs,
|
||||
className = "",
|
||||
size = "md",
|
||||
showCounts = true,
|
||||
}: AdminFilterTabsProps) {
|
||||
const sizes = sizeClasses[size];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
flex rounded-lg border border-[var(--admin-border)] bg-[var(--admin-card-bg-alt)]
|
||||
${sizes.container} ${className}
|
||||
`}
|
||||
role="tablist"
|
||||
aria-label="Filter tabs"
|
||||
>
|
||||
{tabs.map((tab) => {
|
||||
const isActive = activeTab === tab.value;
|
||||
const isDisabled = tab.disabled ?? false;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={tab.value}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
disabled={isDisabled}
|
||||
onClick={() => !isDisabled && onTabChange(tab.value)}
|
||||
className={`
|
||||
flex items-center gap-1.5 rounded-lg font-semibold transition-all duration-150
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
${sizes.tab}
|
||||
${isActive
|
||||
? `
|
||||
bg-white text-emerald-700
|
||||
border border-emerald-200
|
||||
shadow-sm
|
||||
`
|
||||
: `
|
||||
text-stone-600
|
||||
hover:text-emerald-700
|
||||
hover:bg-emerald-50/50
|
||||
`
|
||||
}
|
||||
`}
|
||||
>
|
||||
{/* Tab Icon */}
|
||||
{tab.icon && (
|
||||
<span className={`flex-shrink-0 ${isActive ? "text-emerald-600" : "text-stone-400"}`}>
|
||||
{tab.icon}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Tab Label */}
|
||||
<span>{tab.label}</span>
|
||||
|
||||
{/* Count Badge */}
|
||||
{showCounts && tab.count !== undefined && (
|
||||
<span
|
||||
className={`
|
||||
ml-0.5 inline-flex h-4 min-w-[1rem] items-center justify-center rounded-full px-1
|
||||
text-[10px] font-bold
|
||||
${isActive
|
||||
? "bg-[var(--admin-accent)] text-white"
|
||||
: "bg-[var(--admin-border)] text-[var(--admin-text-muted)]"
|
||||
}
|
||||
`}
|
||||
>
|
||||
{tab.count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Convenience components for common tab configurations
|
||||
|
||||
export type StatusFilter = "all" | "active" | "inactive" | "pending" | "draft" | string;
|
||||
|
||||
type StatusFilterTabsProps = {
|
||||
activeTab: StatusFilter;
|
||||
onTabChange: (value: StatusFilter) => void;
|
||||
counts?: {
|
||||
all?: number;
|
||||
active?: number;
|
||||
inactive?: number;
|
||||
pending?: number;
|
||||
draft?: number;
|
||||
};
|
||||
className?: string;
|
||||
size?: "sm" | "md";
|
||||
};
|
||||
|
||||
export function AdminStatusFilterTabs({
|
||||
activeTab,
|
||||
onTabChange,
|
||||
counts,
|
||||
className = "",
|
||||
size = "md",
|
||||
}: StatusFilterTabsProps) {
|
||||
const defaultTabs: TabItem[] = [
|
||||
{ value: "all", label: "All", count: counts?.all },
|
||||
{ value: "active", label: "Active", count: counts?.active },
|
||||
{ value: "pending", label: "Pending", count: counts?.pending },
|
||||
{ value: "draft", label: "Draft", count: counts?.draft },
|
||||
{ value: "inactive", label: "Inactive", count: counts?.inactive },
|
||||
];
|
||||
|
||||
return (
|
||||
<AdminFilterTabs
|
||||
activeTab={activeTab}
|
||||
onTabChange={onTabChange}
|
||||
tabs={defaultTabs}
|
||||
className={className}
|
||||
size={size}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// View mode tabs (table/card view)
|
||||
type ViewMode = "table" | "cards" | "list" | string;
|
||||
|
||||
type ViewModeTabsProps = {
|
||||
activeTab: ViewMode;
|
||||
onTabChange: (value: ViewMode) => void;
|
||||
showTable?: boolean;
|
||||
showCards?: boolean;
|
||||
showList?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const TableIcon = () => (
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 10h18M3 14h18m-9-4v8m-7 0h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const CardsIcon = () => (
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const ListIcon = () => (
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 10h16M4 14h16M4 18h16" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export function AdminViewModeTabs({
|
||||
activeTab,
|
||||
onTabChange,
|
||||
showTable = true,
|
||||
showCards = true,
|
||||
showList = false,
|
||||
className = "",
|
||||
}: ViewModeTabsProps) {
|
||||
const tabs: TabItem[] = [];
|
||||
|
||||
if (showTable) tabs.push({ value: "table", label: "Table", icon: <TableIcon /> });
|
||||
if (showCards) tabs.push({ value: "cards", label: "Cards", icon: <CardsIcon /> });
|
||||
if (showList) tabs.push({ value: "list", label: "List", icon: <ListIcon /> });
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
flex rounded-lg border border-[var(--admin-border)] bg-white p-1
|
||||
${className}
|
||||
`}
|
||||
role="tablist"
|
||||
aria-label="View mode"
|
||||
>
|
||||
{tabs.map((tab) => {
|
||||
const isActive = activeTab === tab.value;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={tab.value}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
onClick={() => onTabChange(tab.value)}
|
||||
className={`
|
||||
flex items-center justify-center rounded-lg p-1.5 transition-all duration-150
|
||||
${isActive
|
||||
? `
|
||||
bg-[var(--admin-accent-light)] text-[var(--admin-accent-text)]
|
||||
border border-[var(--admin-accent)]
|
||||
shadow-[var(--admin-shadow-sm)]
|
||||
`
|
||||
: `
|
||||
text-[var(--admin-text-muted)]
|
||||
hover:text-[var(--admin-text-secondary)]
|
||||
hover:bg-[var(--admin-bg-subtle)]
|
||||
`
|
||||
}
|
||||
`}
|
||||
>
|
||||
{tab.icon}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -33,72 +33,71 @@ export function AdminInput({
|
||||
}
|
||||
|
||||
// Styled text input wrapper
|
||||
type AdminTextInputProps = {
|
||||
value: string;
|
||||
type AdminTextInputProps = Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange' | 'value' | 'type'> & {
|
||||
value: string | number;
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
placeholder?: string;
|
||||
type?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function AdminTextInput({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
type = "text",
|
||||
disabled,
|
||||
className = ""
|
||||
className = "",
|
||||
maxLength,
|
||||
autoComplete,
|
||||
step,
|
||||
min,
|
||||
max,
|
||||
...rest
|
||||
}: AdminTextInputProps) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
className={`w-full rounded-xl border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] placeholder:text-[var(--admin-text-muted)] disabled:bg-[var(--admin-bg-subtle)] disabled:text-[var(--admin-text-muted)] ${className}`}
|
||||
className={`w-full rounded-xl border border-[var(--admin-border)] bg-[var(--admin-card-bg)] px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20 placeholder:text-[var(--admin-text-muted)] disabled:opacity-50 ${className}`}
|
||||
maxLength={maxLength}
|
||||
autoComplete={autoComplete}
|
||||
step={step}
|
||||
min={min}
|
||||
max={max}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Styled textarea wrapper
|
||||
type AdminTextareaProps = {
|
||||
value: string;
|
||||
type AdminTextareaProps = Omit<React.TextareaHTMLAttributes<HTMLTextAreaElement>, 'onChange' | 'value'> & {
|
||||
value: string | number;
|
||||
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
|
||||
placeholder?: string;
|
||||
rows?: number;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function AdminTextarea({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
rows = 3,
|
||||
disabled,
|
||||
className = ""
|
||||
className = "",
|
||||
...rest
|
||||
}: AdminTextareaProps) {
|
||||
return (
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
rows={rows}
|
||||
disabled={disabled}
|
||||
className={`w-full rounded-xl border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] placeholder:text-[var(--admin-text-muted)] resize-none disabled:bg-[var(--admin-bg-subtle)] disabled:text-[var(--admin-text-muted)] ${className}`}
|
||||
className={`w-full rounded-xl border border-[var(--admin-border)] bg-[var(--admin-card-bg)] px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20 placeholder:text-[var(--admin-text-muted)] resize-none disabled:opacity-50 ${className}`}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Styled select wrapper
|
||||
type AdminSelectProps = {
|
||||
value: string;
|
||||
type AdminSelectProps = Omit<React.SelectHTMLAttributes<HTMLSelectElement>, 'onChange' | 'value'> & {
|
||||
value: string | number;
|
||||
onChange: (e: React.ChangeEvent<HTMLSelectElement>) => void;
|
||||
options: { value: string; label: string }[];
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function AdminSelect({
|
||||
@@ -106,14 +105,16 @@ export function AdminSelect({
|
||||
onChange,
|
||||
options,
|
||||
disabled,
|
||||
className = ""
|
||||
className = "",
|
||||
...rest
|
||||
}: AdminSelectProps) {
|
||||
return (
|
||||
<select
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
className={`w-full rounded-xl border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] disabled:bg-[var(--admin-bg-subtle)] disabled:text-[var(--admin-text-muted)] ${className}`}
|
||||
className={`w-full rounded-xl border border-[var(--admin-border)] bg-[var(--admin-card-bg)] px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20 disabled:opacity-50 ${className}`}
|
||||
{...rest}
|
||||
>
|
||||
{options.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
@@ -147,7 +148,7 @@ export function AdminCheckbox({
|
||||
checked={checked}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
className="h-4 w-4 rounded border-[var(--admin-border)] text-[var(--admin-accent)] focus:ring-[var(--admin-accent)]"
|
||||
className="h-4 w-4 rounded border-[var(--admin-border)] text-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/30 focus:ring-offset-1 cursor-pointer"
|
||||
/>
|
||||
<span className="text-sm font-medium text-[var(--admin-text-secondary)]">{label}</span>
|
||||
</label>
|
||||
|
||||
@@ -33,7 +33,7 @@ export default function AdminModal({ title, subtitle, onClose, children, maxWidt
|
||||
<div
|
||||
className={`relative w-full ${maxWidth} rounded-2xl`}
|
||||
style={{
|
||||
backgroundColor: "#ffffff",
|
||||
backgroundColor: "var(--admin-card-bg)",
|
||||
border: "1px solid var(--admin-border)",
|
||||
boxShadow: "0 25px 50px -12px rgba(60, 56, 37, 0.35), 0 12px 24px -8px rgba(60, 56, 37, 0.2)",
|
||||
}}
|
||||
|
||||
@@ -1,39 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import PageHeader from "./PageHeader";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
// Re-export PageHeader with description prop mapped to subtitle for backward compatibility
|
||||
type AdminPageHeaderProps = {
|
||||
breadcrumb?: { label: string; href?: string }[];
|
||||
icon?: ReactNode;
|
||||
title: string;
|
||||
description?: string;
|
||||
actions?: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function AdminPageHeader({ breadcrumb, title, description, actions }: AdminPageHeaderProps) {
|
||||
return (
|
||||
<div className="mb-6">
|
||||
{breadcrumb && breadcrumb.length > 0 && (
|
||||
<nav className="flex items-center gap-2 text-xs text-[var(--admin-text-muted)] mb-4">
|
||||
{breadcrumb.map((item, i) => (
|
||||
<span key={i} className="flex items-center gap-2">
|
||||
{item.href ? (
|
||||
<Link href={item.href} className="hover:text-[var(--admin-text-secondary)] transition-colors">{item.label}</Link>
|
||||
) : (
|
||||
<span className="text-[var(--admin-text-secondary)]">{item.label}</span>
|
||||
)}
|
||||
{i < breadcrumb.length - 1 && <span>/</span>}
|
||||
</span>
|
||||
))}
|
||||
</nav>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-[var(--admin-text-primary)] tracking-tight">{title}</h1>
|
||||
{description && <p className="mt-1 text-sm text-[var(--admin-text-muted)]">{description}</p>}
|
||||
</div>
|
||||
{actions && <div className="flex items-center gap-3">{actions}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
export default function AdminPageHeader(props: AdminPageHeaderProps) {
|
||||
// Convert description to subtitle for PageHeader
|
||||
const { description, ...rest } = props;
|
||||
return <PageHeader {...rest} subtitle={description} />;
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
"use client";
|
||||
|
||||
import { InputHTMLAttributes, forwardRef } from "react";
|
||||
|
||||
type AdminSearchInputProps = InputHTMLAttributes<HTMLInputElement> & {
|
||||
/** Search icon element (defaults to magnifying glass) */
|
||||
icon?: React.ReactNode;
|
||||
/** Icon position */
|
||||
iconPosition?: "left" | "right";
|
||||
/** Callback when clear button is clicked */
|
||||
onClear?: () => void;
|
||||
/** Show clear button when value exists */
|
||||
showClear?: boolean;
|
||||
/** Container CSS class */
|
||||
containerClassName?: string;
|
||||
/** Input wrapper CSS class */
|
||||
inputClassName?: string;
|
||||
};
|
||||
|
||||
const SearchIcon = () => (
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const ClearIcon = () => (
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const AdminSearchInput = forwardRef<HTMLInputElement, AdminSearchInputProps>(({
|
||||
icon,
|
||||
iconPosition = "left",
|
||||
onClear,
|
||||
showClear = true,
|
||||
containerClassName = "",
|
||||
inputClassName = "",
|
||||
value,
|
||||
className = "",
|
||||
...props
|
||||
}, ref) => {
|
||||
const searchIcon = icon ?? <SearchIcon />;
|
||||
const hasValue = value !== undefined && value !== "";
|
||||
const shouldShowClear = showClear && hasValue && onClear;
|
||||
|
||||
const inputBaseClasses = `
|
||||
w-full rounded-xl border border-[var(--admin-border)] bg-white
|
||||
py-2 pl-10 pr-4 text-sm text-[var(--admin-text-primary)]
|
||||
outline-none transition-colors duration-150
|
||||
placeholder:text-[var(--admin-text-muted)]
|
||||
focus:border-[var(--admin-accent)]
|
||||
disabled:bg-[var(--admin-bg-subtle)] disabled:cursor-not-allowed
|
||||
`;
|
||||
|
||||
return (
|
||||
<div className={`relative flex-1 min-w-[12rem] ${containerClassName}`}>
|
||||
{/* Left Icon */}
|
||||
{iconPosition === "left" && (
|
||||
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--admin-text-muted)] pointer-events-none">
|
||||
{searchIcon}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input */}
|
||||
<input
|
||||
ref={ref}
|
||||
type="search"
|
||||
value={value}
|
||||
className={`${inputBaseClasses} ${inputClassName} ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
{/* Right Icon (Clear button or custom) */}
|
||||
{iconPosition === "right" && !shouldShowClear && (
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-[var(--admin-text-muted)] pointer-events-none">
|
||||
{searchIcon}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Clear Button */}
|
||||
{shouldShowClear && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClear}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)] transition-colors p-0.5"
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<ClearIcon />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
AdminSearchInput.displayName = "AdminSearchInput";
|
||||
|
||||
export default AdminSearchInput;
|
||||
@@ -0,0 +1,84 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
type PageHeaderProps = {
|
||||
/** Breadcrumb navigation items */
|
||||
breadcrumb?: { label: string; href?: string }[];
|
||||
/** Icon displayed before the title */
|
||||
icon?: ReactNode;
|
||||
/** Main title text */
|
||||
title: string;
|
||||
/** Optional subtitle/description below the title */
|
||||
subtitle?: string;
|
||||
/** Optional action buttons/elements to display on the right */
|
||||
actions?: ReactNode;
|
||||
/** Additional CSS classes */
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function PageHeader({
|
||||
breadcrumb,
|
||||
icon,
|
||||
title,
|
||||
subtitle,
|
||||
actions,
|
||||
className = "",
|
||||
}: PageHeaderProps) {
|
||||
return (
|
||||
<div className={`mb-6 ${className}`}>
|
||||
{/* Breadcrumb Navigation */}
|
||||
{breadcrumb && breadcrumb.length > 0 && (
|
||||
<nav className="flex items-center gap-2 text-xs text-[var(--admin-text-muted)] mb-4">
|
||||
{breadcrumb.map((crumb, index) => (
|
||||
<span key={index} className="flex items-center gap-2">
|
||||
{crumb.href ? (
|
||||
<Link
|
||||
href={crumb.href}
|
||||
className="hover:text-[var(--admin-text-secondary)] transition-colors"
|
||||
>
|
||||
{crumb.label}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-[var(--admin-text-secondary)]">{crumb.label}</span>
|
||||
)}
|
||||
{index < breadcrumb.length - 1 && (
|
||||
<span className="text-[var(--admin-text-muted)]">/</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</nav>
|
||||
)}
|
||||
|
||||
{/* Title Row with Icon and Actions */}
|
||||
<div className="flex items-center justify-between">
|
||||
{/* Left: Icon + Title + Subtitle */}
|
||||
<div className="flex items-center gap-4">
|
||||
{icon && (
|
||||
<div className="flex items-center justify-center w-12 h-12 rounded-2xl bg-[var(--admin-accent-light)] text-[var(--admin-accent)] shadow-[var(--admin-shadow-sm)]">
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-[var(--admin-text-primary)] tracking-tight">
|
||||
{title}
|
||||
</h1>
|
||||
{subtitle && (
|
||||
<p className="mt-1 text-sm text-[var(--admin-text-muted)]">
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Action Buttons */}
|
||||
{actions && (
|
||||
<div className="flex items-center gap-3">
|
||||
{actions}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,11 +12,14 @@ export { default as AdminActionMenu, AdminActionButton } from "./AdminActionMenu
|
||||
export { default as AdminPagination, AdminSimplePagination } from "./AdminPagination";
|
||||
export { default as AdminBadge, AdminStatusBadge, AdminCountBadge } from "./AdminBadge";
|
||||
|
||||
// Design system components
|
||||
export { default as AdminButton, AdminIconButton } from "./AdminButton";
|
||||
export { default as PageHeader } from "./PageHeader";
|
||||
export { default as AdminSearchInput } from "./AdminSearchInput";
|
||||
export { default as AdminFilterTabs, AdminStatusFilterTabs, AdminViewModeTabs } from "./AdminFilterTabs";
|
||||
|
||||
// Form elements
|
||||
export { AdminInput, AdminTextInput, AdminTextarea, AdminSelect, AdminCheckbox, AdminSpinner, AdminLoadingOverlay } from "./AdminFormElements";
|
||||
|
||||
// Modal component
|
||||
export { default as AdminModal } from "./AdminModal";
|
||||
|
||||
// Re-export GlassModal for backward compatibility
|
||||
// Modal component - GlassModal is the standard modal (max-w-lg default), AdminModal is a smaller variant (max-w-md default)
|
||||
export { default as GlassModal } from "@/components/admin/GlassModal";
|
||||
@@ -599,12 +599,6 @@ export default function RouteTraceDashboard({
|
||||
{Icons.camera("w-3.5 h-3.5 sm:w-4 sm:h-4")} <span className="hidden sm:inline">Scan QR</span>
|
||||
</button>
|
||||
<FsmaReportModal brandId={brandId} />
|
||||
<button
|
||||
onClick={() => setShowQuickNew(true)}
|
||||
className="rounded-xl border border-[var(--admin-border)] bg-white px-3 sm:px-4 py-2 text-xs sm:text-sm font-semibold text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] transition-colors"
|
||||
>
|
||||
{Icons.plus("w-3.5 h-3.5 sm:w-4 sm:h-4")} <span className="hidden sm:inline">New Lot</span>
|
||||
</button>
|
||||
<Link
|
||||
href="/admin/route-trace/lots"
|
||||
className="rounded-xl border border-[var(--admin-border)] bg-white px-3 sm:px-4 py-2 text-xs sm:text-sm font-semibold text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] transition-colors flex items-center gap-1.5 sm:gap-2"
|
||||
|
||||
@@ -90,7 +90,11 @@ export default function StickerPreviewModal({ lot, onClose }: { lot: LotDetail;
|
||||
<h3 className="text-base font-semibold text-stone-900 flex items-center gap-2">{Icons.printer("h-5 w-5")} Print Sticker</h3>
|
||||
<p className="text-xs text-stone-400 mt-0.5">{lot.lot_number} · {lot.crop_type}</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-stone-400 hover:text-stone-600 text-xl leading-none">✕</button>
|
||||
<button onClick={onClose} className="text-stone-400 hover:text-stone-600">
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-5">
|
||||
|
||||
Reference in New Issue
Block a user