Admin AI Tools page: unified design system styling, provider selector with OpenAI turd emoji, free-text model input with exact ID warning

This commit is contained in:
2026-06-02 02:21:11 +00:00
parent 809e0061ca
commit 15e939ad7e
116 changed files with 14991 additions and 5326 deletions
+34
View File
@@ -0,0 +1,34 @@
import { redirect } from "next/navigation";
import { getAdminUser } from "@/lib/admin-permissions";
import { getBrands } from "@/actions/admin/users";
import { getStripeConnectStatus } from "@/actions/stripe-connect";
import AdvancedSettingsClient from "@/components/admin/AdvancedSettingsClient";
export const metadata = {
title: "Advanced Settings - Route Commerce Admin",
description: "Developer settings, API integrations, and advanced configurations",
};
export default async function AdvancedSettingsPage() {
const adminUser = await getAdminUser();
if (!adminUser) redirect("/login");
// Only admins can access advanced settings
if (!adminUser.can_manage_settings && adminUser.role !== "platform_admin") {
redirect("/admin");
}
const brandId = adminUser.brand_id ?? "";
const { brands } = await getBrands();
// Get Stripe Connect status
const stripeConnect = brandId ? await getStripeConnectStatus(brandId) : null;
return (
<AdvancedSettingsClient
brandId={brandId}
brands={brands}
stripeConnect={stripeConnect}
/>
);
}
@@ -1,26 +1,5 @@
import { redirect } from "next/navigation";
import { getAdminUser } from "@/lib/admin-permissions";
import { getCampaignAnalytics } from "@/actions/harvest-reach/campaigns";
import CommunicationsPage from "@/components/admin/CommunicationsPage";
export default async function AnalyticsPage() {
const adminUser = await getAdminUser();
if (!adminUser || !adminUser.can_manage_messages) redirect("/admin/pickup");
const effectiveBrandId = adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
const analytics = await getCampaignAnalytics(effectiveBrandId);
return (
<div className="min-h-screen bg-stone-100 px-6 py-10">
<div className="mx-auto max-w-6xl">
<CommunicationsPage
campaigns={[]}
templates={[]}
activeTab="analytics"
brandId={effectiveBrandId}
initialAnalytics={analytics}
/>
</div>
</div>
);
export default function AnalyticsPage() {
redirect("/admin/communications");
}
@@ -2,6 +2,7 @@ import { redirect } from "next/navigation";
import { getAdminUser } from "@/lib/admin-permissions";
import { getCommunicationCampaigns, getCampaignById } from "@/actions/communications/campaigns";
import { getCommunicationTemplates } from "@/actions/communications/templates";
import { getHarvestReachSegments } from "@/actions/harvest-reach/segments";
import CommunicationsPage from "@/components/admin/CommunicationsPage";
export default async function CampaignEditPage({
@@ -10,36 +11,30 @@ export default async function CampaignEditPage({
params: Promise<{ id: string }>;
}) {
const adminUser = await getAdminUser();
if (!adminUser) redirect("/admin");
if (!adminUser.can_manage_messages) redirect("/admin/pickup");
if (!adminUser || !adminUser.can_manage_messages) {
redirect("/admin/pickup");
}
const { id } = await params;
const isNew = id === "new";
const effectiveBrandId = adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
const [campaignsResult, templatesResult] = await Promise.all([
const [campaignsResult, templatesResult, segmentsResult] = await Promise.all([
getCommunicationCampaigns(effectiveBrandId),
getCommunicationTemplates(effectiveBrandId),
getHarvestReachSegments(effectiveBrandId),
]);
const campaign = isNew ? undefined : id ? await getCampaignById(id) : undefined;
return (
<main className="min-h-screen bg-stone-100 px-6 py-10">
<div className="mx-auto max-w-6xl">
<div className="mb-8">
<h1 className="text-3xl font-bold text-stone-950">Harvest Reach</h1>
</div>
<CommunicationsPage
campaigns={campaignsResult.success ? campaignsResult.campaigns : []}
templates={templatesResult.success ? templatesResult.templates : []}
activeTab="campaigns"
brandId={adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de"}
editCampaign={campaign}
editMode={isNew ? "new" : "edit"}
/>
</div>
</main>
<CommunicationsPage
campaigns={campaignsResult.success ? campaignsResult.campaigns : []}
templates={templatesResult.success ? templatesResult.templates : []}
brandId={effectiveBrandId}
initialSegments={segmentsResult.success ? segmentsResult.segments : []}
editCampaign={campaign}
editMode={isNew ? "new" : "edit"}
/>
);
}
+12 -18
View File
@@ -1,37 +1,31 @@
import { redirect } from "next/navigation";
import { getAdminUser } from "@/lib/admin-permissions";
import { getHarvestReachCampaigns } from "@/actions/harvest-reach/campaigns";
import { getCommunicationCampaigns } from "@/actions/communications/campaigns";
import { getCommunicationTemplates } from "@/actions/communications/templates";
import { getHarvestReachSegments } from "@/actions/harvest-reach/segments";
import CommunicationsPage from "@/components/admin/CommunicationsPage";
export default async function ComposePage() {
const adminUser = await getAdminUser();
if (!adminUser || !adminUser.can_manage_messages) redirect("/admin/pickup");
if (!adminUser || !adminUser.can_manage_messages) {
redirect("/admin/pickup");
}
const effectiveBrandId = adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
const [campaignsResult, templatesResult, segmentsResult] = await Promise.all([
getHarvestReachCampaigns(effectiveBrandId),
getCommunicationCampaigns(effectiveBrandId),
getCommunicationTemplates(effectiveBrandId),
getHarvestReachSegments(effectiveBrandId),
]);
const campaigns = campaignsResult.success ? campaignsResult.campaigns : [];
const templates = templatesResult.success ? templatesResult.templates : [];
const segments = segmentsResult.success ? segmentsResult.segments : [];
return (
<div className="min-h-screen bg-stone-100 px-6 py-10">
<div className="mx-auto max-w-6xl">
<CommunicationsPage
campaigns={campaigns}
templates={templates}
activeTab="compose"
brandId={effectiveBrandId}
initialSegments={segments}
/>
</div>
</div>
<CommunicationsPage
campaigns={campaignsResult.success ? campaignsResult.campaigns : []}
templates={templatesResult.success ? templatesResult.templates : []}
brandId={effectiveBrandId}
initialSegments={segmentsResult.success ? segmentsResult.segments : []}
editMode="new"
/>
);
}
+3 -40
View File
@@ -1,42 +1,5 @@
import { redirect } from "next/navigation";
import { getAdminUser } from "@/lib/admin-permissions";
import { getCommunicationCampaigns } from "@/actions/communications/campaigns";
import { getCommunicationTemplates } from "@/actions/communications/templates";
import { getContacts } from "@/actions/communications/contacts";
import CommunicationsPage from "@/components/admin/CommunicationsPage";
export default async function ContactsPage() {
const adminUser = await getAdminUser();
if (!adminUser) redirect("/admin");
if (!adminUser.can_manage_messages) redirect("/admin/pickup");
const brandId = adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
const [campaignsResult, templatesResult, contactsResult] = await Promise.all([
getCommunicationCampaigns(brandId),
getCommunicationTemplates(brandId),
getContacts({ brandId, limit: 50 }),
]);
return (
<main className="min-h-screen bg-stone-100 px-6 py-10">
<div className="mx-auto max-w-6xl">
<div className="mb-8">
<h1 className="text-3xl font-bold text-stone-950">Harvest Reach</h1>
<p className="mt-2 text-stone-500">
Manage email campaigns, templates, contacts, and message history.
</p>
</div>
<CommunicationsPage
campaigns={campaignsResult.success ? campaignsResult.campaigns : []}
templates={templatesResult.success ? templatesResult.templates : []}
activeTab="contacts"
brandId={brandId}
initialContacts={contactsResult.success ? contactsResult.contacts : []}
initialContactTotal={contactsResult.success ? contactsResult.total : 0}
/>
</div>
</main>
);
}
export default function ContactsPage() {
redirect("/admin/communications");
}
+2 -38
View File
@@ -1,41 +1,5 @@
import { redirect } from "next/navigation";
import { getAdminUser } from "@/lib/admin-permissions";
import { getCommunicationCampaigns } from "@/actions/communications/campaigns";
import { getCommunicationTemplates } from "@/actions/communications/templates";
import { getMessageLogs } from "@/actions/communications/send";
import CommunicationsPage from "@/components/admin/CommunicationsPage";
export default async function LogsPage() {
const adminUser = await getAdminUser();
if (!adminUser) redirect("/admin");
if (!adminUser.can_manage_messages) redirect("/admin/pickup");
const brandId = adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
const [campaignsResult, templatesResult, logsResult] = await Promise.all([
getCommunicationCampaigns(brandId),
getCommunicationTemplates(brandId),
getMessageLogs({ brandId, limit: 200 }),
]);
return (
<main className="min-h-screen bg-stone-100 px-6 py-10">
<div className="mx-auto max-w-6xl">
<div className="mb-8">
<h1 className="text-3xl font-bold text-stone-950">Harvest Reach</h1>
<p className="mt-2 text-stone-500">
Manage email campaigns, templates, and message history.
</p>
</div>
<CommunicationsPage
campaigns={campaignsResult.success ? campaignsResult.campaigns : []}
templates={templatesResult.success ? templatesResult.templates : []}
activeTab="logs"
brandId={brandId}
initialLogs={logsResult.success ? logsResult.logs : []}
/>
</div>
</main>
);
export default function LogsPage() {
redirect("/admin/communications");
}
+11 -28
View File
@@ -8,9 +8,11 @@ import CommunicationsPage from "@/components/admin/CommunicationsPage";
export default async function CommunicationsRootPage() {
const adminUser = await getAdminUser();
if (!adminUser || !adminUser.can_manage_messages) redirect("/admin/pickup");
if (!adminUser || !adminUser.can_manage_messages) {
redirect("/admin/pickup");
}
const effectiveBrandId = adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
const effectiveBrandId = adminUser!.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
const [campaignsResult, templatesResult, segmentsResult, analyticsResult] = await Promise.all([
getCommunicationCampaigns(effectiveBrandId),
@@ -20,31 +22,12 @@ export default async function CommunicationsRootPage() {
]);
return (
<main className="min-h-screen px-6 py-10">
<div className="mx-auto max-w-6xl">
{/* Breadcrumb */}
<nav className="flex items-center gap-2 text-xs text-stone-500 mb-6">
<a href="/admin" className="hover:text-stone-600 transition-colors">Admin</a>
<span>/</span>
<span className="text-stone-600">Harvest Reach</span>
</nav>
<div className="mb-8">
<h1 className="text-3xl font-bold text-stone-950">Harvest Reach</h1>
<p className="mt-2 text-stone-500">
Manage email campaigns, templates, and message history.
</p>
</div>
<CommunicationsPage
campaigns={campaignsResult.success ? campaignsResult.campaigns : []}
templates={templatesResult.success ? templatesResult.templates : []}
activeTab="campaigns"
brandId={effectiveBrandId}
initialSegments={segmentsResult.success ? segmentsResult.segments : []}
initialAnalytics={analyticsResult}
/>
</div>
</main>
<CommunicationsPage
campaigns={campaignsResult.success ? campaignsResult.campaigns : []}
templates={templatesResult.success ? templatesResult.templates : []}
brandId={effectiveBrandId}
initialSegments={segmentsResult.success ? segmentsResult.segments : []}
initialAnalytics={analyticsResult}
/>
);
}
+2 -24
View File
@@ -1,27 +1,5 @@
import { redirect } from "next/navigation";
import { getAdminUser } from "@/lib/admin-permissions";
import { getHarvestReachSegments } from "@/actions/harvest-reach/segments";
import CommunicationsPage from "@/components/admin/CommunicationsPage";
export default async function SegmentsPage() {
const adminUser = await getAdminUser();
if (!adminUser || !adminUser.can_manage_messages) redirect("/admin/pickup");
const effectiveBrandId = adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
const segmentsResult = await getHarvestReachSegments(effectiveBrandId);
const segments = segmentsResult.success ? segmentsResult.segments : [];
return (
<div className="min-h-screen bg-stone-100 px-6 py-10">
<div className="mx-auto max-w-6xl">
<CommunicationsPage
campaigns={[]}
templates={[]}
activeTab="segments"
brandId={effectiveBrandId}
initialSegments={segments}
/>
</div>
</div>
);
export default function SegmentsPage() {
redirect("/admin/communications");
}
+32 -24
View File
@@ -1,9 +1,7 @@
import { redirect } from "next/navigation";
import { getAdminUser } from "@/lib/admin-permissions";
import { getCommunicationCampaigns } from "@/actions/communications/campaigns";
import { getCommunicationTemplates } from "@/actions/communications/templates";
import { getCommunicationSettings } from "@/actions/communications/settings";
import CommunicationsPage from "@/components/admin/CommunicationsPage";
import CommunicationSettingsForm from "@/components/admin/CommunicationSettingsForm";
export default async function SettingsPage() {
const adminUser = await getAdminUser();
@@ -11,31 +9,41 @@ export default async function SettingsPage() {
if (!adminUser.can_manage_messages) redirect("/admin/pickup");
const brandId = adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
const [campaignsResult, templatesResult, settingsResult] = await Promise.all([
getCommunicationCampaigns(brandId),
getCommunicationTemplates(brandId),
getCommunicationSettings(brandId),
]);
const settingsResult = await getCommunicationSettings(brandId);
return (
<main className="min-h-screen bg-stone-100 px-6 py-10">
<div className="mx-auto max-w-6xl">
<div className="mb-8">
<h1 className="text-3xl font-bold text-stone-950">Harvest Reach</h1>
<p className="mt-2 text-stone-500">
Manage email campaigns, templates, and message history.
</p>
<div className="min-h-screen bg-[var(--admin-bg)]">
<div className="px-4 sm:px-6 md:px-8 pt-4 sm:pt-6">
{/* Back button */}
<a
href="/admin/communications"
className="inline-flex items-center gap-2 text-sm text-stone-500 hover:text-stone-700 mb-4"
>
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m15 18-6-6 6-6"/>
</svg>
Back to Harvest Reach
</a>
{/* Header */}
<div className="flex items-center gap-3 mb-6">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-emerald-600">
<svg className="h-5 w-5 text-white" 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>
</div>
<div>
<h1 className="text-xl sm:text-2xl font-black text-[var(--admin-text-primary)] tracking-tight">Harvest Reach Settings</h1>
<p className="text-xs text-[var(--admin-text-muted)]">Configure email and SMS integration</p>
</div>
</div>
<CommunicationsPage
campaigns={campaignsResult.success ? campaignsResult.campaigns : []}
templates={templatesResult.success ? templatesResult.templates : []}
activeTab="settings"
brandId={brandId}
initialSettings={settingsResult}
/>
{/* Settings Form */}
<div className="rounded-2xl border border-[var(--admin-border)] bg-white p-4 sm:p-6">
<CommunicationSettingsForm settings={settingsResult} brandId={brandId} />
</div>
</div>
</main>
</div>
);
}
@@ -1,6 +1,8 @@
import { redirect } from "next/navigation";
import { getAdminUser } from "@/lib/admin-permissions";
import { getCommunicationTemplates, getTemplateById } from "@/actions/communications/templates";
import { getCommunicationCampaigns } from "@/actions/communications/campaigns";
import { getHarvestReachSegments } from "@/actions/harvest-reach/segments";
import CommunicationsPage from "@/components/admin/CommunicationsPage";
export default async function TemplateEditPage({
@@ -9,31 +11,30 @@ export default async function TemplateEditPage({
params: Promise<{ id: string }>;
}) {
const adminUser = await getAdminUser();
if (!adminUser) redirect("/admin");
if (!adminUser.can_manage_messages) redirect("/admin/pickup");
if (!adminUser || !adminUser.can_manage_messages) {
redirect("/admin/pickup");
}
const { id } = await params;
const isNew = id === "new";
const effectiveBrandId = adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
const [campaignsResult, templatesResult, segmentsResult] = await Promise.all([
getCommunicationCampaigns(effectiveBrandId),
getCommunicationTemplates(effectiveBrandId),
getHarvestReachSegments(effectiveBrandId),
]);
const templatesResult = await getCommunicationTemplates();
const template = isNew ? undefined : id ? await getTemplateById(id) : undefined;
return (
<main className="min-h-screen bg-stone-100 px-6 py-10">
<div className="mx-auto max-w-6xl">
<div className="mb-8">
<h1 className="text-3xl font-bold text-stone-950">Harvest Reach</h1>
</div>
<CommunicationsPage
campaigns={[]}
templates={templatesResult.success ? templatesResult.templates : []}
activeTab="templates"
brandId={adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de"}
editTemplate={template}
editMode={isNew ? "new" : "edit"}
/>
</div>
</main>
<CommunicationsPage
campaigns={campaignsResult.success ? campaignsResult.campaigns : []}
templates={templatesResult.success ? templatesResult.templates : []}
brandId={effectiveBrandId}
initialSegments={segmentsResult.success ? segmentsResult.segments : []}
editTemplate={template}
editMode={isNew ? "new" : "edit"}
/>
);
}
@@ -1,36 +1,5 @@
import { redirect } from "next/navigation";
import { getAdminUser } from "@/lib/admin-permissions";
import { getCommunicationCampaigns } from "@/actions/communications/campaigns";
import { getCommunicationTemplates } from "@/actions/communications/templates";
import CommunicationsPage from "@/components/admin/CommunicationsPage";
export default async function TemplatesPage() {
const adminUser = await getAdminUser();
if (!adminUser) redirect("/admin");
if (!adminUser.can_manage_messages) redirect("/admin/pickup");
const [campaignsResult, templatesResult] = await Promise.all([
getCommunicationCampaigns(adminUser.brand_id ?? undefined),
getCommunicationTemplates(adminUser.brand_id ?? undefined),
]);
return (
<main className="min-h-screen bg-stone-100 px-6 py-10">
<div className="mx-auto max-w-6xl">
<div className="mb-8">
<h1 className="text-3xl font-bold text-stone-950">Harvest Reach</h1>
<p className="mt-2 text-stone-500">
Manage email campaigns, templates, and message history.
</p>
</div>
<CommunicationsPage
campaigns={campaignsResult.success ? campaignsResult.campaigns : []}
templates={templatesResult.success ? templatesResult.templates : []}
activeTab="templates"
brandId={adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de"}
/>
</div>
</main>
);
export default function TemplatesPage() {
redirect("/admin/communications");
}
+4 -3
View File
@@ -2,6 +2,7 @@ import AdminSidebar from "@/components/admin/AdminSidebar";
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
import { getAdminUser } from "@/lib/admin-permissions";
import { redirect } from "next/navigation";
import "@/styles/admin-design-system.css";
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
const adminUser = await getAdminUser();
@@ -10,8 +11,8 @@ export default async function AdminLayout({ children }: { children: React.ReactN
return (
<>
<AdminSidebar userRole={null} />
<div className="min-h-screen lg:pl-60" style={{ backgroundColor: "#fdfaf6" }}>
<AdminAccessDenied message="Your account does not have admin access." />
<div className="min-h-screen lg:pl-60 admin-section" style={{ backgroundColor: "var(--admin-bg)" }}>
<AdminAccessDenied message="Your account does not have admin access." />
</div>
</>
);
@@ -24,7 +25,7 @@ export default async function AdminLayout({ children }: { children: React.ReactN
return (
<>
<AdminSidebar userRole={adminUser.role} />
<div className="min-h-screen lg:pl-60" style={{ backgroundColor: "#fdfaf6" }}>
<div className="min-h-screen lg:pl-60 admin-section" style={{ backgroundColor: "var(--admin-bg)" }}>
{children}
</div>
</>
+100 -92
View File
@@ -4,6 +4,7 @@ import OrderEditForm from "@/components/admin/OrderEditForm";
import OrderPaymentSection from "@/components/admin/OrderPaymentSection";
import OrderPickupAction from "@/components/admin/OrderPickupAction";
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
import { formatDate } from "@/lib/format-date";
import { redirect } from "next/navigation";
type OrderDetailPageProps = {
@@ -12,6 +13,10 @@ type OrderDetailPageProps = {
}>;
};
function formatCurrency(amount: number) {
return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(amount);
}
export default async function OrderDetailPage({ params }: OrderDetailPageProps) {
const { id } = await params;
@@ -19,15 +24,17 @@ export default async function OrderDetailPage({ params }: OrderDetailPageProps)
if (!order) {
return (
<main className="min-h-screen bg-stone-100 px-6 py-12">
<main className="min-h-screen px-6 py-10">
<div className="mx-auto max-w-4xl">
<h1 className="text-3xl font-bold text-red-600">Order not found</h1>
<a
href="/admin/orders"
className="mt-4 inline-block text-stone-500 hover:text-stone-700"
className="inline-flex items-center gap-2 text-sm text-stone-500 hover:text-stone-700"
>
Back to Orders
</a>
<div className="mt-8 rounded-2xl border border-red-200 bg-red-50 p-8 text-center">
<p className="text-lg font-semibold text-red-700">Order not found</p>
</div>
</div>
</main>
);
@@ -46,58 +53,69 @@ export default async function OrderDetailPage({ params }: OrderDetailPageProps)
const brandId = order.stops?.brand_id ?? null;
const subtotal = Number(order.subtotal);
const discount_amount = Number(order.discount_amount ?? 0);
const total = Math.max(0, subtotal - discount_amount);
const taxAmount = Number(order.tax_amount ?? 0);
const total = subtotal + taxAmount - discount_amount;
return (
<main className="min-h-screen bg-stone-100 px-6 py-12">
<div className="mx-auto max-w-4xl">
<a
href="/admin/orders"
className="text-sm text-stone-500 hover:text-stone-700"
>
Back to Orders
</a>
{/* Customer info */}
<div className="card mt-6">
<div className="flex items-start justify-between gap-4">
<main className="min-h-screen px-6 py-8">
<div className="mx-auto max-w-4xl space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<a
href="/admin/orders"
className="flex h-9 w-9 items-center justify-center rounded-xl border border-stone-200 bg-white text-stone-500 hover:bg-stone-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="M15 19l-7-7 7-7" />
</svg>
</a>
<div>
<p className="text-sm font-semibold uppercase tracking-wide text-stone-500">
Customer
</p>
<h1 className="mt-2 text-3xl font-bold text-stone-950">
{order.customer_name}
</h1>
</div>
<div className="flex items-center gap-3">
<span
className={`shrink-0 rounded-full px-3 py-1 text-xs font-bold uppercase tracking-wide ${
order.pickup_complete
? "bg-emerald-100 text-emerald-700"
: "bg-yellow-100 text-yellow-700"
}`}
>
{order.pickup_complete ? "Picked Up" : "Pending"}
</span>
<OrderPickupAction
orderId={order.id}
brandId={brandId}
currentlyPickedUp={order.pickup_complete}
/>
<h1 className="text-2xl font-bold text-stone-950 tracking-tight">Order Details</h1>
<p className="text-sm text-stone-500">{formatDate(order.created_at)}</p>
</div>
</div>
<div className="flex items-center gap-3">
<span className={`rounded-full px-3 py-1 text-xs font-bold ${
order.pickup_complete
? "bg-green-50 text-green-700 border border-green-200"
: "bg-amber-50 text-amber-700 border border-amber-200"
}`}>
{order.pickup_complete ? "✓ Picked Up" : "⏳ Pending"}
</span>
{order.payment_processor && (
<span className="rounded-full bg-violet-50 px-2.5 py-0.5 text-xs font-semibold text-violet-700 border border-violet-200">
{order.payment_processor}
</span>
)}
</div>
</div>
<div className="mt-6 grid grid-cols-1 gap-4 sm:grid-cols-2">
{/* Customer info */}
<div className="rounded-2xl border border-stone-200 bg-white p-6 shadow-sm">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-stone-400">Customer</p>
<h2 className="mt-1 text-2xl font-bold text-stone-950">{order.customer_name}</h2>
</div>
<OrderPickupAction
orderId={order.id}
brandId={brandId}
currentlyPickedUp={order.pickup_complete}
/>
</div>
<div className="mt-5 grid grid-cols-2 gap-4">
{order.customer_phone && (
<div>
<p className="text-sm font-medium text-stone-500">Phone</p>
<p className="mt-1 text-lg text-stone-950">{order.customer_phone}</p>
<p className="text-xs font-semibold text-stone-400">Phone</p>
<p className="mt-0.5 text-sm font-medium text-stone-700">{order.customer_phone}</p>
</div>
)}
{order.customer_email && (
<div>
<p className="text-sm font-medium text-stone-500">Email</p>
<p className="mt-1 text-lg text-stone-950">{order.customer_email}</p>
<p className="text-xs font-semibold text-stone-400">Email</p>
<p className="mt-0.5 text-sm font-medium text-stone-700">{order.customer_email}</p>
</div>
)}
</div>
@@ -105,84 +123,76 @@ export default async function OrderDetailPage({ params }: OrderDetailPageProps)
{/* Stop info */}
{order.stops && (
<div className="card mt-6">
<p className="text-sm font-medium text-stone-500">Stop</p>
<p className="mt-1 text-xl font-semibold text-stone-950">
<div className="rounded-2xl border border-stone-200 bg-white p-6 shadow-sm">
<p className="text-xs font-semibold uppercase tracking-wider text-stone-400">Pickup Location</p>
<p className="mt-1 text-lg font-bold text-stone-950">
{order.stops.city}, {order.stops.state}
</p>
<p className="mt-1 text-sm text-stone-500">{order.stops.date}</p>
<p className="mt-0.5 text-sm text-stone-500">{formatDate(order.stops.date)}</p>
</div>
)}
{/* Order items */}
<div className="card mt-6">
<h2 className="text-2xl font-bold text-stone-950">Order Items</h2>
<div className="rounded-2xl border border-stone-200 bg-white p-6 shadow-sm">
<h3 className="text-lg font-bold text-stone-950">Order Items</h3>
{order.order_items && order.order_items.length > 0 ? (
<div className="mt-4 space-y-3">
<div className="mt-4 divide-y divide-stone-100">
{order.order_items.map((item: any) => (
<div
key={item.id}
className="flex items-center justify-between border-b border-stone-200 pb-3 last:border-0"
className="flex items-center justify-between py-3"
>
<div>
<p className="font-medium text-stone-950">
<p className="font-medium text-stone-900">
{item.products?.name ?? "Unknown Product"}
</p>
<p className="text-sm text-stone-500">Qty: {item.quantity}</p>
<p className="text-xs text-stone-400">Qty: {item.quantity} × {formatCurrency(Number(item.price))}</p>
</div>
<p className="font-semibold text-stone-950">
${(Number(item.price) * item.quantity).toFixed(2)}
<p className="font-semibold text-stone-900">
{formatCurrency(Number(item.price) * item.quantity)}
</p>
</div>
))}
</div>
) : (
<p className="mt-4 text-stone-500">No items found.</p>
<p className="mt-4 text-sm text-stone-400">No items found</p>
)}
<div className="mt-6 border-t border-stone-200 pt-6 space-y-2">
{/* Totals */}
<div className="mt-6 border-t border-stone-100 pt-6 space-y-2">
<div className="flex justify-between text-sm">
<span className="text-stone-500">Subtotal</span>
<span className="text-stone-950">${subtotal.toFixed(2)}</span>
<span className="text-stone-900">{formatCurrency(subtotal)}</span>
</div>
{Number(order.tax_amount ?? 0) > 0 && (
{taxAmount > 0 && (
<div className="flex justify-between text-sm">
<span className="text-stone-500">Tax ({order.tax_rate ? `${(Number(order.tax_rate) * 100).toFixed(3)}%` : ""})</span>
<span className="text-stone-950">${Number(order.tax_amount).toFixed(2)}</span>
<span className="text-stone-500">Tax</span>
<span className="text-stone-900">{formatCurrency(taxAmount)}</span>
</div>
)}
{discount_amount > 0 && (
<>
<div className="flex justify-between text-sm">
<span className="text-stone-500">Discount</span>
<span className="text-red-600">-${discount_amount.toFixed(2)}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-stone-500">Discount</span>
<span className="text-red-600">-{formatCurrency(discount_amount)}</span>
{order.discount_reason && (
<p className="text-xs text-stone-500">{order.discount_reason}</p>
<span className="text-xs text-stone-400 ml-2">({order.discount_reason})</span>
)}
</>
</div>
)}
<div className="flex justify-between text-xl font-bold text-stone-950 pt-2 border-t border-stone-200">
<div className="flex justify-between text-lg font-bold text-stone-950 pt-2 border-t border-stone-200">
<span>Total</span>
<span>${(subtotal + Number(order.tax_amount ?? 0) - discount_amount).toFixed(2)}</span>
<span>{formatCurrency(total)}</span>
</div>
{order.tax_location && (
<p className="text-xs text-stone-500">Tax sourced from: {order.tax_location}</p>
)}
</div>
</div>
{/* Payment & Refunds */}
<div className="card mt-6">
<h2 className="text-2xl font-bold text-stone-950">
Payment & Refunds
</h2>
<p className="mt-1 text-stone-600">
Record payment details and manage refunds for this order.
</p>
<div className="rounded-2xl border border-stone-200 bg-white p-6 shadow-sm">
<h3 className="text-lg font-bold text-stone-950">Payment & Refunds</h3>
<p className="mt-1 text-sm text-stone-500">Record payment details and manage refunds</p>
<div className="mt-6">
<div className="mt-5">
<OrderPaymentSection
orderId={order.id}
brandId={brandId}
@@ -198,25 +208,23 @@ export default async function OrderDetailPage({ params }: OrderDetailPageProps)
</div>
{/* Edit form */}
<div className="card mt-6">
<h2 className="text-2xl font-bold text-stone-950">Edit Order</h2>
<p className="mt-1 text-stone-600">
Update customer details, pricing, and status.
</p>
<div className="rounded-2xl border border-stone-200 bg-white p-6 shadow-sm">
<h3 className="text-lg font-bold text-stone-950">Edit Order</h3>
<p className="mt-1 text-sm text-stone-500">Update customer details, pricing, and status</p>
<div className="mt-6">
<div className="mt-5">
<OrderEditForm order={order as any} brandId={brandId} />
</div>
</div>
{/* Internal notes display */}
{/* Internal notes */}
{order.internal_notes && (
<div className="card mt-6 border-amber-300 bg-amber-50">
<p className="text-sm font-medium text-amber-700">Internal Notes</p>
<p className="mt-1 text-stone-950">{order.internal_notes}</p>
<div className="rounded-2xl border border-amber-200 bg-amber-50 p-6">
<p className="text-xs font-semibold uppercase tracking-wider text-amber-600">Internal Notes</p>
<p className="mt-1 text-sm text-stone-700">{order.internal_notes}</p>
</div>
)}
</div>
</main>
);
}
}
+29 -7
View File
@@ -29,12 +29,34 @@ export default async function AdminOrdersPage() {
: orders;
return (
<main className="min-h-screen">
<AdminOrdersPanel
initialOrders={brandOrders}
initialStops={brandStops}
brandId={adminUser?.brand_id ?? null}
/>
</main>
<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-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">
<path d="M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4Z"/>
<path d="M3 6h18"/>
<path d="M16 10a4 4 0 0 1-8 0"/>
</svg>
</div>
<div>
<h1 className="text-xl sm:text-2xl font-black text-[var(--admin-text-primary)] tracking-tight">Orders</h1>
<p className="text-xs text-[var(--admin-text-muted)]">Manage customer orders and pickup status</p>
</div>
</div>
</div>
{/* Content */}
<div className="px-4 sm:px-6 md:px-8 py-4 sm:py-6">
<div className="rounded-2xl border border-[var(--admin-border)] bg-white overflow-hidden">
<AdminOrdersPanel
initialOrders={brandOrders}
initialStops={brandStops}
brandId={adminUser?.brand_id ?? null}
/>
</div>
</div>
</div>
);
}
+12 -288
View File
@@ -2,136 +2,10 @@ import Link from "next/link";
import { getAdminUser } from "@/lib/admin-permissions";
import { isFeatureEnabled } from "@/lib/feature-flags";
import { getBrandPlanInfo } from "@/actions/billing/stripe-portal";
import DashboardHeader from "@/components/admin/DashboardHeader";
import DashboardClient from "@/components/admin/DashboardClient";
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
type Section = {
title: string;
href: string;
description: string;
group: "operations" | "fulfillment" | "management" | "tools";
addonKey?: string;
upgradeText?: string;
prominent?: boolean;
};
const sections: Section[] = [
{
title: "Orders",
href: "/admin/orders",
description: "View orders, pickup status, fulfillment, and customer details.",
group: "operations",
prominent: true,
},
{
title: "Products",
href: "/admin/products",
description: "Manage products, pricing, shipping type, and availability.",
group: "operations",
prominent: true,
},
{
title: "Tours & Stops",
href: "/admin/stops",
description: "Manage routes, pickup locations, dates, and cutoff times.",
group: "fulfillment",
},
{
title: "Driver Pickup",
href: "/admin/pickup",
description: "Mobile pickup lookup, QR scanning, and completion tools.",
group: "fulfillment",
},
{
title: "Shipping",
href: "/admin/shipping",
description: "FedEx integration, label creation, and shipment tracking.",
group: "fulfillment",
},
{
title: "Reports",
href: "/admin/reports",
description: "Sales, route, product, pickup, and customer reports.",
group: "management",
},
{
title: "Tax Dashboard",
href: "/admin/taxes",
description: "Sales tax collected, breakdown by state, and exportable reports.",
group: "management",
},
{
title: "Settings",
href: "/admin/settings",
description: "Users, billing, brand, integrations, payments, and shipping.",
group: "management",
},
{
title: "Harvest Reach",
href: "/admin/communications",
description: "Email campaigns, stop blast, templates, and audience segments.",
group: "tools",
addonKey: "harvest_reach",
upgradeText: "Enable to access email & SMS marketing",
},
{
title: "Wholesale Portal",
href: "/admin/wholesale",
description: "Standalone B2B portal with custom pricing, credit limits, and net-30.",
group: "tools",
addonKey: "wholesale_portal",
upgradeText: "Enable to unlock B2B buyer portal",
},
{
title: "Import Center",
href: "/admin/import",
description: "AI-powered import for products, orders, contacts, and stops.",
group: "tools",
addonKey: "ai_tools",
upgradeText: "Enable AI import with smart column mapping",
},
{
title: "AI Intelligence",
href: "/admin/settings/ai",
description: "Campaign writer, pricing advisor, and report explainer.",
group: "tools",
addonKey: "ai_tools",
upgradeText: "Enable AI tools for marketing and pricing",
},
{
title: "Time Tracking",
href: "/admin/time-tracking",
description: "Worker clock-in/out, hours tracking, and overtime management.",
group: "tools",
addonKey: "time_tracking",
upgradeText: "Enable for field worker time tracking",
},
{
title: "Water Log",
href: "/admin/water-log",
description: "Irrigation tracking and water usage reporting.",
group: "tools",
addonKey: "water_log",
upgradeText: "Enable for agricultural water tracking",
},
{
title: "Route Trace",
href: "/admin/route-trace",
description: "Lot tracking, QR stickers, hauling board, and supply chain traceability.",
group: "tools",
addonKey: "route_trace",
upgradeText: "Enable for field-to-delivery traceability",
},
];
const groupMeta = {
operations: { label: "Core Operations", cols: "grid-cols-1 md:grid-cols-2 lg:grid-cols-4" },
fulfillment: { label: "Fulfillment & Logistics", cols: "grid-cols-1 md:grid-cols-3" },
management: { label: "Business Management", cols: "grid-cols-1 md:grid-cols-3" },
tools: { label: "Tools & Add-ons", cols: "grid-cols-1 md:grid-cols-2 lg:grid-cols-4" },
} as const;
export default async function AdminPage() {
const adminUser = await getAdminUser();
const isStoreEmployee = adminUser?.role === "store_employee";
@@ -142,11 +16,12 @@ export default async function AdminPage() {
adminUser?.brand_id === TUXEDO_BRAND_ID &&
adminUser?.can_manage_water_log);
const enabledAddons: Record<string, boolean> = {};
let enabledAddons: Record<string, boolean> = {};
let planTier = "starter";
let usage = { users: 0, stops_this_month: 0, products: 0 };
let limits = { max_users: 1, max_stops_monthly: 10, max_products: 25 };
let brandDisplayName = "Admin";
if (adminUser?.brand_id) {
for (const key of ["harvest_reach", "wholesale_portal", "water_log", "ai_tools", "sms_campaigns", "square_sync", "route_trace"] as const) {
enabledAddons[key] = await isFeatureEnabled(adminUser.brand_id, key);
@@ -211,166 +86,15 @@ export default async function AdminPage() {
);
}
const allGroups = [
{
key: "operations" as const,
label: groupMeta.operations.label,
cols: groupMeta.operations.cols,
sections: sections.filter((s) => s.group === "operations"),
},
{
key: "fulfillment" as const,
label: groupMeta.fulfillment.label,
cols: groupMeta.fulfillment.cols,
sections: sections.filter((s) => s.group === "fulfillment"),
},
{
key: "management" as const,
label: groupMeta.management.label,
cols: groupMeta.management.cols,
sections: sections.filter((s) => s.group === "management"),
},
{
key: "tools" as const,
label: groupMeta.tools.label,
cols: groupMeta.tools.cols,
sections: sections.filter((s) => s.group === "tools"),
},
];
const usagePct = {
users: limits.max_users > 0 ? (usage.users / limits.max_users) * 100 : 0,
stops: limits.max_stops_monthly > 0 ? (usage.stops_this_month / limits.max_stops_monthly) * 100 : 0,
products: limits.max_products > 0 ? (usage.products / limits.max_products) * 100 : 0,
};
return (
<main className="min-h-screen px-6 py-10">
<div className="mx-auto max-w-7xl space-y-10">
{/* Header */}
<DashboardHeader
brandId={adminUser?.brand_id ?? null}
brandName={brandDisplayName}
planTier={planTier}
/>
{/* Usage bar */}
<div className="rounded-2xl border border-stone-200 bg-white p-6 shadow-sm">
<div className="flex items-center gap-5 mb-5">
<span className={`rounded-full px-4 py-1.5 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"
}`}>
{planTier.charAt(0).toUpperCase() + planTier.slice(1)} Plan
</span>
<span className="text-sm text-stone-500">{adminUser?.brand_id ? "Tuxedo Corn" : "All Brands"}</span>
</div>
<div className="grid grid-cols-3 gap-8">
{[
{ label: "Users", value: `${usage.users}/${limits.max_users}`, pct: usagePct.users },
{ label: "Stops / month", value: `${usage.stops_this_month}/${limits.max_stops_monthly}`, pct: usagePct.stops },
{ label: "Products", value: `${usage.products}/${limits.max_products}`, pct: usagePct.products },
].map(({ label, value, pct }) => (
<div key={label}>
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-medium text-stone-600">{label}</span>
<span className="text-sm font-semibold text-stone-900">{value}</span>
</div>
<div className="h-1.5 rounded-full bg-stone-200 overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-500 ${
pct > 85 ? "bg-gradient-to-r from-amber-500 to-amber-400" : "bg-gradient-to-r from-emerald-500 to-emerald-400"
}`}
style={{ width: `${Math.min(pct, 100)}%` }}
/>
</div>
</div>
))}
</div>
</div>
{/* Grouped sections */}
{allGroups.map(({ key, label, cols, sections: groupSections }) => {
if (groupSections.length === 0) return null;
return (
<div key={key}>
<div className="flex items-center gap-4 mb-5">
<p className="text-[11px] font-bold uppercase tracking-widest text-stone-600 flex-shrink-0">{label}</p>
<div className="h-px flex-1 bg-gradient-to-r from-stone-300 to-transparent" />
</div>
<div className={`grid ${cols} gap-4`}>
{groupSections.map((section) => {
if (section.title === "Water Log" && !isWaterLogVisible) return null;
if (section.title === "Route Trace" && !enabledAddons["route_trace"]) return null;
const isAddon = Boolean(section.addonKey);
const isEnabled = section.addonKey ? (enabledAddons[section.addonKey] ?? false) : true;
const isProminent = section.prominent;
return (
<Link
key={section.title}
href={section.href}
className={[
"group relative flex flex-col rounded-2xl border bg-white p-5 transition-all hover:-translate-y-0.5",
isAddon && !isEnabled
? "border-stone-200 shadow-sm opacity-75 hover:opacity-100"
: isProminent
? "border-stone-200 shadow-md shadow-stone-200/50 hover:shadow-lg"
: "border-stone-200 shadow-sm hover:shadow-md",
].join(" ")}
>
<div className="flex items-center justify-between mb-4">
<div className={`flex h-9 w-9 items-center justify-center rounded-xl ${
isAddon && !isEnabled
? "bg-stone-100 text-stone-400"
: isProminent
? "bg-emerald-100 text-emerald-600"
: "bg-violet-100 text-violet-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">
Add-on
</span>
)}
{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">
Active
</span>
)}
{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">
Core
</span>
)}
</div>
<h3 className="text-sm font-semibold leading-tight text-stone-950">
{section.title}
</h3>
<p className={`mt-2 text-sm leading-relaxed ${
isAddon && !isEnabled ? "text-stone-500" : "text-stone-600"
}`}>
{section.description}
</p>
{isAddon && !isEnabled && section.upgradeText && (
<p className="mt-3 text-xs text-amber-700 font-medium">{section.upgradeText}</p>
)}
</Link>
);
})}
</div>
</div>
);
})}
</div>
</main>
<DashboardClient
brandId={adminUser?.brand_id ?? null}
brandName={brandDisplayName}
planTier={planTier}
isWaterLogVisible={isWaterLogVisible}
enabledAddons={enabledAddons}
usage={usage}
limits={limits}
/>
);
}
+45 -56
View File
@@ -1,11 +1,19 @@
import { supabase } from "@/lib/supabase";
import ProductTableClient from "@/components/admin/ProductTableClient";
import { getAdminUser } from "@/lib/admin-permissions";
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
import { redirect } from "next/navigation";
import Link from "next/link";
import { getPaymentSettings } from "@/actions/payments";
import ProductSyncBanner from "@/components/admin/ProductSyncBanner";
import ProductsClient from "@/components/admin/ProductsClient";
// Icon components
const Icons = {
package: (className: string) => (
<svg className={className} 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 async function AdminProductsPage() {
const adminUser = await getAdminUser();
@@ -13,18 +21,17 @@ export default async function AdminProductsPage() {
if (!adminUser) return <AdminAccessDenied />;
if (!adminUser.can_manage_products) {
redirect("/admin/pickup");
return (
<main className="min-h-screen bg-[var(--admin-bg)] px-4 sm:px-6 md:px-8 py-6 sm:py-8">
<div className="mx-auto max-w-6xl">
<h1 className="text-2xl sm:text-3xl font-bold text-red-600">Access Denied</h1>
<p className="mt-2 text-sm text-stone-500">You do not have permission to manage products.</p>
</div>
</main>
);
}
const brandId =
adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
const [settingsResult] = await Promise.all([
getPaymentSettings(brandId),
]);
const hasSquareToken = !!(
settingsResult.success && settingsResult.settings?.square_access_token
);
const brandId = adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
let query = supabase
.from("products")
@@ -38,15 +45,12 @@ export default async function AdminProductsPage() {
deleted_at,
image_url,
brand_id,
is_taxable,
brands (
name
)
is_taxable
`)
.is("deleted_at", null)
.order("name");
if (adminUser?.brand_id) {
if (adminUser.brand_id) {
query = query.eq("brand_id", adminUser.brand_id);
}
@@ -54,15 +58,10 @@ export default async function AdminProductsPage() {
if (error) {
return (
<main className="min-h-screen px-6 py-10">
<main className="min-h-screen bg-[var(--admin-bg)] px-4 sm:px-6 md:px-8 py-6 sm:py-8">
<div className="mx-auto max-w-6xl">
<nav className="mb-6 flex items-center gap-2 text-xs text-stone-500">
<Link href="/admin" className="hover:text-stone-600 transition-colors">Admin</Link>
<span>/</span>
<span className="text-stone-600">Products</span>
</nav>
<h1 className="text-3xl font-bold text-red-600">Error loading products</h1>
<pre className="mt-4 rounded-xl bg-white border border-stone-200 p-4 text-sm text-stone-600">
<h1 className="text-2xl sm:text-3xl font-bold text-red-600">Error loading products</h1>
<pre className="mt-4 rounded-xl bg-white border border-[var(--admin-border)] p-4 text-sm text-stone-600">
{error.message}
</pre>
</div>
@@ -71,36 +70,26 @@ export default async function AdminProductsPage() {
}
return (
<main className="min-h-screen px-6 py-10">
<div className="mx-auto max-w-6xl">
{/* Breadcrumb */}
<nav className="mb-6 flex items-center gap-2 text-xs text-stone-500">
<Link href="/admin" className="hover:text-stone-600 transition-colors">Admin</Link>
<span>/</span>
<span className="text-stone-600">Products</span>
</nav>
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-stone-950 tracking-tight">Products</h1>
<p className="mt-2 text-sm text-stone-500">
Manage your product catalog.
</p>
<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-emerald-600">
{Icons.package("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">Products</h1>
<p className="text-xs text-[var(--admin-text-muted)]">Manage your product catalog</p>
</div>
<Link
href="/admin/products/new"
className="rounded-xl bg-emerald-600 hover:bg-emerald-500 active:bg-emerald-700 px-5 py-3 font-semibold text-white text-sm transition-colors shadow-sm"
>
Add Product
</Link>
</div>
<div className="mt-8 overflow-hidden rounded-2xl bg-white border border-stone-200 shadow-sm">
<ProductSyncBanner brandId={brandId} hasSquareToken={hasSquareToken} />
<ProductTableClient products={products ?? []} />
</div>
</div>
</main>
{/* Content */}
<div className="px-4 sm:px-6 md:px-8 py-4 sm:py-6">
<div className="rounded-2xl border border-[var(--admin-border)] bg-white overflow-hidden">
<ProductsClient products={products ?? []} brandId={brandId} />
</div>
</div>
</div>
);
}
@@ -11,6 +11,12 @@ import {
getRecentLotEvents,
} from "@/actions/route-trace/lots";
export const metadata = {
title: "Lot Lookup | Route Trace",
description: "Search and lookup harvest lots by lot number, bin ID, or container ID. Quick traceability search for produce distribution.",
keywords: ["lot lookup", "search lots", "find lot", "traceability search", "lot number search", "bin lookup"],
};
export default async function LookupPage() {
const adminUser = await getAdminUser();
if (!adminUser) redirect("/login");
+25 -6
View File
@@ -1,18 +1,37 @@
import { redirect } from "next/navigation";
import { cookies } from "next/headers";
import { getAdminUser } from "@/lib/admin-permissions";
import { isFeatureEnabled } from "@/lib/feature-flags";
import { getRouteTraceLotDetail, getLotOrders } from "@/actions/route-trace/lots";
import LotDetailPanel from "@/components/route-trace/LotDetailPanel";
import RouteTraceNav from "@/components/route-trace/RouteTraceNav";
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const detailResult = await getRouteTraceLotDetail(id);
const lotNumber = detailResult.success && detailResult.lot ? detailResult.lot.lot_number : null;
return {
title: lotNumber ? `Lot ${lotNumber} | Route Trace` : "Lot Detail | Route Trace",
description: lotNumber
? `View complete traceability details for harvest lot ${lotNumber}. Track events, order fulfillment, and FSMA compliance records.`
: "View harvest lot traceability details and order fulfillment information.",
keywords: lotNumber ? [`lot ${lotNumber}`, "lot detail", "traceability", "harvest lot", "lot events"] : ["lot detail", "traceability", "harvest lot"],
};
}
export default async function LotDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const adminUser = await getAdminUser();
if (!adminUser) redirect("/login");
const cookieStore = await cookies();
const devSession = cookieStore.get("dev_session")?.value;
const isDevMode = !!devSession;
const effectiveBrandId = adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
const enabled = await isFeatureEnabled(effectiveBrandId, "route_trace");
const enabled = isDevMode ? true : await isFeatureEnabled(effectiveBrandId, "route_trace");
if (!enabled) redirect("/admin/settings/apps?reason=route_trace");
const [detailResult, ordersResult] = await Promise.all([
@@ -22,11 +41,11 @@ export default async function LotDetailPage({ params }: { params: Promise<{ id:
if (!detailResult.success || !detailResult.lot) {
return (
<div className="min-h-screen bg-zinc-950 px-6 py-10">
<div className="min-h-screen bg-stone-50 px-6 py-10">
<div className="mx-auto max-w-2xl">
<div className="rounded-xl border border-red-200 bg-zinc-900 p-6 text-center">
<div className="rounded-xl border border-red-200 bg-white p-6 text-center">
<p className="text-red-600">Lot not found</p>
<a href="/admin/route-trace/lots" className="mt-3 inline-block text-sm text-blue-400 hover:underline"> Back to Lots</a>
<a href="/admin/route-trace/lots" className="mt-3 inline-block text-sm text-stone-500 hover:text-stone-700"> Back to Lots</a>
</div>
</div>
</div>
@@ -34,10 +53,10 @@ export default async function LotDetailPage({ params }: { params: Promise<{ id:
}
return (
<div className="min-h-screen bg-zinc-950 px-6 py-10">
<div className="min-h-screen bg-stone-50 px-6 py-10">
<div className="mx-auto max-w-3xl">
<div className="mb-6">
<a href="/admin/route-trace/lots" className="text-sm text-zinc-500 hover:text-zinc-300"> Back to Lots</a>
<a href="/admin/route-trace/lots" className="text-sm text-stone-500 hover:text-stone-700"> Back to Lots</a>
</div>
<RouteTraceNav activeTab="lots" />
<LotDetailPanel
+14 -3
View File
@@ -1,23 +1,34 @@
import { redirect } from "next/navigation";
import { cookies } from "next/headers";
import { getAdminUser } from "@/lib/admin-permissions";
import { isFeatureEnabled } from "@/lib/feature-flags";
import LotCreateForm from "@/components/route-trace/LotCreateForm";
import RouteTraceNav from "@/components/route-trace/RouteTraceNav";
export const metadata = {
title: "Create New Lot | Route Trace",
description: "Create a new harvest lot for traceability tracking. Record crop type, field location, worker, quantity, and harvest date.",
keywords: ["create lot", "new lot", "harvest entry", "lot creation", "traceability", "harvest tracking"],
};
export default async function NewLotPage() {
const adminUser = await getAdminUser();
if (!adminUser) redirect("/login");
const cookieStore = await cookies();
const devSession = cookieStore.get("dev_session")?.value;
const isDevMode = !!devSession;
const effectiveBrandId = adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
const enabled = await isFeatureEnabled(effectiveBrandId, "route_trace");
const enabled = isDevMode ? true : await isFeatureEnabled(effectiveBrandId, "route_trace");
if (!enabled) redirect("/admin/settings/apps?reason=route_trace");
return (
<div className="min-h-screen bg-zinc-950 px-6 py-10">
<div className="min-h-screen bg-stone-50 px-6 py-10">
<div className="mx-auto max-w-2xl">
<div className="mb-6">
<a href="/admin/route-trace/lots" className="text-sm text-zinc-500 hover:text-zinc-300"> Back to Lots</a>
<a href="/admin/route-trace/lots" className="text-sm text-stone-500 hover:text-stone-700"> Back to Lots</a>
</div>
<RouteTraceNav activeTab="lots" />
<LotCreateForm brandId={effectiveBrandId} />
+12 -1
View File
@@ -1,4 +1,5 @@
import { redirect } from "next/navigation";
import { cookies } from "next/headers";
import { getAdminUser } from "@/lib/admin-permissions";
import { isFeatureEnabled } from "@/lib/feature-flags";
import { getRouteTraceLots } from "@/actions/route-trace/lots";
@@ -11,13 +12,23 @@ import {
getRecentLotEvents,
} from "@/actions/route-trace/lots";
export const metadata = {
title: "Harvest Lots | Route Trace",
description: "View and manage all harvest lots. Track active lots, in-transit shipments, and completed deliveries across your produce distribution network.",
keywords: ["harvest lots", "lot management", "lot tracking", "produce lots", "harvest tracking", "field lots"],
};
export default async function LotsPage() {
const adminUser = await getAdminUser();
if (!adminUser) redirect("/login");
const cookieStore = await cookies();
const devSession = cookieStore.get("dev_session")?.value;
const isDevMode = !!devSession;
const effectiveBrandId = adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
const enabled = await isFeatureEnabled(effectiveBrandId, "route_trace");
const enabled = isDevMode ? true : await isFeatureEnabled(effectiveBrandId, "route_trace");
if (!enabled) redirect("/admin/settings/apps?reason=route_trace");
const [statsResult, lotsResult, haulingResult, yieldResult, invResult, eventsResult] = await Promise.all([
+64 -11
View File
@@ -1,4 +1,5 @@
import { redirect } from "next/navigation";
import { cookies } from "next/headers";
import { getAdminUser } from "@/lib/admin-permissions";
import { isFeatureEnabled } from "@/lib/feature-flags";
import {
@@ -11,13 +12,43 @@ import {
} from "@/actions/route-trace/lots";
import RouteTracePage from "@/components/route-trace/RouteTracePage";
export const metadata = {
title: "Route Trace | Harvest Traceability Dashboard",
description: "Track produce lots from field to delivery. Manage harvest events, hauling logistics, inventory by crop, and FSMA compliance reporting for fresh produce distribution.",
keywords: ["route trace", "harvest traceability", "lot tracking", "farm to fork", "FSMA compliance", "food safety", "produce distribution", "haul tracking", "inventory management"],
openGraph: {
title: "Route Trace | Harvest Traceability",
description: "Complete lot traceability from field to delivery with real-time tracking and FSMA compliance.",
type: "website",
},
};
// Route Trace icon component
const Icons = {
routeTrace: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 3v18h18" />
<path d="M7 16l4-8 4 5 5-9" />
<circle cx="7" cy="16" r="1.5" fill="currentColor" />
<circle cx="11" cy="8" r="1.5" fill="currentColor" />
<circle cx="15" cy="13" r="1.5" fill="currentColor" />
<circle cx="20" cy="4" r="1.5" fill="currentColor" />
</svg>
),
};
export default async function RouteTraceDashboardPage() {
const adminUser = await getAdminUser();
if (!adminUser) redirect("/login");
const cookieStore = await cookies();
const devSession = cookieStore.get("dev_session")?.value;
const isDevMode = !!devSession;
const effectiveBrandId = adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
const enabled = await isFeatureEnabled(effectiveBrandId, "route_trace");
// Bypass feature check in dev mode (dev_session cookie present)
const enabled = isDevMode ? true : await isFeatureEnabled(effectiveBrandId, "route_trace");
if (!enabled) redirect("/admin/settings/apps?reason=route_trace");
const [statsResult, lotsResult, haulingResult, yieldResult, invResult, eventsResult] = await Promise.all([
@@ -40,15 +71,37 @@ export default async function RouteTraceDashboardPage() {
const recentActivity = eventsResult.success ? eventsResult.events : [];
return (
<RouteTracePage
stats={stats}
recentLots={recentLots}
haulingLots={haulingLots}
fieldYield={fieldYield}
inventoryByCrop={inventoryByCrop}
recentActivity={recentActivity}
brandId={effectiveBrandId}
lots={allLots}
/>
<main className="min-h-screen bg-[var(--admin-bg)]">
{/* 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-start justify-between gap-4 mb-4 sm:mb-6">
<div className="space-y-1 flex-1 min-w-0">
<div className="flex items-center gap-2 sm:gap-3">
<div className="flex h-10 w-10 sm:h-12 sm:w-12 items-center justify-center rounded-xl bg-emerald-600">
{Icons.routeTrace("h-5 w-5 sm:h-6 sm:w-6 text-white")}
</div>
<div>
<h1 className="text-2xl sm:text-3xl font-black text-[var(--admin-text-primary)] tracking-tight">Route Trace</h1>
<p className="text-xs sm:text-sm text-[var(--admin-text-muted)]">Field-to-delivery lot traceability</p>
</div>
</div>
</div>
</div>
</div>
{/* Content */}
<div className="px-4 sm:px-6 md:px-8 py-4 sm:py-6">
<RouteTracePage
stats={stats}
recentLots={recentLots}
haulingLots={haulingLots}
fieldYield={fieldYield}
inventoryByCrop={inventoryByCrop}
recentActivity={recentActivity}
brandId={effectiveBrandId}
lots={allLots}
/>
</div>
</main>
);
}
@@ -11,6 +11,12 @@ import {
getRecentLotEvents,
} from "@/actions/route-trace/lots";
export const metadata = {
title: "Route Trace Settings",
description: "Configure Route Trace traceability workflow settings, defaults, and add-on features for your produce distribution operation.",
keywords: ["route trace settings", "traceability configuration", "FSMA settings", "traceability defaults"],
};
export default async function RouteTraceSettingsPage() {
const adminUser = await getAdminUser();
if (!adminUser) redirect("/login");
File diff suppressed because it is too large Load Diff
+6 -2
View File
@@ -2,12 +2,13 @@ import { redirect } from "next/navigation";
import { getAdminUser } from "@/lib/admin-permissions";
import { getAIProviderSettings } from "@/actions/integrations/ai-providers";
import AIClient from "./AIClient";
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
export default async function AISettingsPage() {
const adminUser = await getAdminUser();
if (!adminUser) redirect("/login");
if (!adminUser) return <AdminAccessDenied />;
const brandId = adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
const brandId = adminUser.brand_id ?? "";
const settings = await getAIProviderSettings(brandId);
const isConnected = !!settings.apiKey;
@@ -19,6 +20,9 @@ export default async function AISettingsPage() {
isConnected={isConnected}
brandId={brandId}
brandName={brandName}
provider={settings.provider}
model={settings.model}
customEndpoint={settings.customEndpoint}
/>
);
}
+72 -21
View File
@@ -2,14 +2,22 @@ import { redirect } from "next/navigation";
import { getAdminUser } from "@/lib/admin-permissions";
import { ADDON_CATALOG, isFeatureEnabled } from "@/lib/feature-flags";
import BrandFeatureCards from "@/components/admin/BrandFeatureCards";
import { AdminPageHeader } from "@/components/admin/design-system";
export default async function AppsSettingsPage() {
type Props = {
searchParams: Promise<{ reason?: string }>;
};
export default async function AppsSettingsPage({ searchParams }: Props) {
const adminUser = await getAdminUser();
if (!adminUser) redirect("/login");
if (!adminUser.can_manage_settings && adminUser.role !== "platform_admin") {
redirect("/admin");
}
const params = await searchParams;
const reason = params.reason;
const brandId = adminUser.brand_id ?? "";
const enabledFeatures: Record<string, boolean> = {};
@@ -17,30 +25,73 @@ export default async function AppsSettingsPage() {
enabledFeatures[key] = await isFeatureEnabled(brandId, key);
}
return (
<main className="min-h-screen bg-stone-100 px-6 py-10">
<div className="mx-auto max-w-4xl">
{/* Breadcrumb */}
<nav className="flex items-center gap-2 text-xs text-stone-500 mb-6">
<a href="/admin" className="hover:text-stone-800 transition-colors">Admin</a>
<span>/</span>
<span className="text-stone-800">Add-ons</span>
</nav>
const featureNames: Record<string, string> = {
route_trace: "Route Trace",
wholesale_portal: "Wholesale Portal",
harvest_reach: "Harvest Reach",
water_log: "Water Log",
ai_tools: "AI Tools",
sms_campaigns: "SMS Campaigns",
square_sync: "Square Sync",
};
<div className="mb-8">
<div className="flex items-center gap-3 mb-2">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-stone-200 border border-stone-300">
<svg className="h-5 w-5 text-stone-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M14.857 17.082a23.848 23.848 0 005.337-1.219 23.75 23.75 0 00-5.337-1.219 23.848 23.848 0 005.337 1.219zM10.5 2.25a2.25 2.25 0 013.165 1.453 2.25 2.25 0 00-1.453 3.165m-6.197 6.197A3.5 3.5 0 017.5 12.75v1.5a3.5 3.5 0 001.5 2.625h1.5a3.5 3.5 0 001.5-2.625V13.5m-6.197 6.197a3.5 3.5 0 001.5 2.625m0 0v1.5a3.5 3.5 0 01-1.5 2.625H5.25m0 0H3.75a2.25 2.25 0 00-2.25 2.25v1.5a2.25 2.25 0 002.25 2.25h1.5m0 0A3.5 3.5 0 0112.75 19.5v1.5a3.5 3.5 0 01-3.5 3.5H8.25m0 0H6.75" />
</svg>
</div>
<h1 className="text-3xl font-bold text-stone-950">Add-ons</h1>
return (
<main className="min-h-screen admin-section px-6 py-10" style={{ backgroundColor: "var(--admin-bg)" }}>
<div className="mx-auto max-w-5xl">
{/* Header with icon */}
<div className="flex items-center gap-4 mb-6">
<div
className="flex h-12 w-12 items-center justify-center rounded-xl"
style={{
background: 'linear-gradient(135deg, rgba(16, 185, 129, 0.15) 0%, rgba(16, 185, 129, 0.08) 100%)',
border: '1px solid rgba(16, 185, 129, 0.2)',
}}
>
<svg className="h-6 w-6" style={{ color: '#059669' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09.542.56.94 1.11.94h2.64c.55 0 1.02-.398 1.11-.94l.213-1.999c.018-.158.04-.315.062-.472a.563.563 0 00-.122-.519l-.79-2.758A.562.562 0 0014.56 0H9.44a.563.563 0 00-.424.264l-.79 2.758a.563.563 0 00-.122.519c.022.157.044.314.062.472l.213 1.999z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M12 15.75a3.75 3.75 0 100-7.5 3.75 3.75 0 000 7.5z" />
</svg>
</div>
<p className="mt-2 text-stone-500">
Enable or disable add-on features for your brand. Changes take effect immediately.
</p>
<AdminPageHeader
breadcrumb={[{ label: "Admin", href: "/admin" }, { label: "Settings" }, { label: "Add-ons" }]}
title="Add-ons"
description="Enable features to extend your platform capabilities"
/>
</div>
{/* Reason banner */}
{reason && featureNames[reason] && (
<div
className="mb-6 rounded-xl p-4"
style={{
background: 'rgba(16, 185, 129, 0.08)',
border: '1px solid rgba(16, 185, 129, 0.2)',
}}
>
<div className="flex items-center gap-3">
<div
className="flex h-10 w-10 items-center justify-center rounded-lg"
style={{
background: 'rgba(16, 185, 129, 0.15)',
}}
>
<svg className="h-5 w-5" style={{ color: '#059669' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
</svg>
</div>
<div>
<h2 className="font-semibold text-stone-950">
{featureNames[reason]} is not enabled
</h2>
<p className="text-sm text-stone-500">
Enable the {featureNames[reason]} add-on below to access this feature.
</p>
</div>
</div>
</div>
)}
{/* Feature cards */}
<BrandFeatureCards brandId={brandId} initialEnabledFeatures={enabledFeatures} />
</div>
</main>
+15 -22
View File
@@ -3,6 +3,11 @@ import { getAdminUser } from "@/lib/admin-permissions";
import { getAdminUsers, getBrands } from "@/actions/admin/users";
import SettingsClient from "@/components/admin/SettingsClient";
export const metadata = {
title: "Settings - Route Commerce Admin",
description: "Manage your brand settings, workers, tasks, and user permissions",
};
export default async function AdminSettingsPage() {
const adminUser = await getAdminUser();
if (!adminUser) redirect("/login");
@@ -14,28 +19,16 @@ export default async function AdminSettingsPage() {
getBrands(),
]);
// Breadcrumb nav (for page context)
const breadcrumb = (
<nav className="flex items-center gap-2 text-xs text-stone-500 mb-3">
<a href="/admin" className="hover:text-stone-600 transition-colors">Admin</a>
<span>/</span>
<span className="text-stone-600">Settings</span>
</nav>
);
return (
<main>
{breadcrumb}
<SettingsClient
brandId={brandId}
users={error ? [] : users}
brands={brands}
currentUser={{
id: adminUser.id ?? adminUser.user_id,
role: adminUser.role,
can_manage_users: adminUser.can_manage_users,
}}
/>
</main>
<SettingsClient
brandId={brandId}
users={error ? [] : users}
brands={brands}
currentUser={{
id: adminUser.id ?? adminUser.user_id,
role: adminUser.role,
can_manage_users: adminUser.can_manage_users,
}}
/>
);
}
+43 -32
View File
@@ -45,48 +45,59 @@ export default async function AdminStopsPage() {
if (error) {
return (
<main className="min-h-screen px-6 py-10">
<div className="mx-auto max-w-6xl">
<nav className="flex items-center gap-2 text-xs text-stone-500 mb-6">
<a href="/admin" className="hover:text-stone-600 transition-colors">Admin</a>
<span>/</span>
<span className="text-stone-600">Tour Stops</span>
</nav>
<h1 className="text-3xl font-bold text-red-600">
Error loading stops
</h1>
<pre className="mt-4 rounded-xl bg-white border border-stone-200 p-4 text-sm text-stone-600">
{error.message}
</pre>
<main className="min-h-screen bg-[var(--admin-bg)]">
<div className="px-4 sm:px-6 md:px-8 py-6 sm:py-8">
<div className="max-w-6xl mx-auto">
<nav className="flex items-center gap-2 text-xs sm:text-sm mb-6">
<a href="/admin" className="text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)]">Admin</a>
<span className="text-[var(--admin-text-muted)]">/</span>
<span className="text-[var(--admin-text-primary)] font-medium">Stops & Routes</span>
</nav>
<h1 className="text-2xl sm:text-3xl font-black text-red-600 tracking-tight">
Error loading stops
</h1>
<pre className="mt-4 rounded-xl bg-white border border-[var(--admin-border)] p-4 text-sm text-[var(--admin-text-secondary)]">
{error.message}
</pre>
</div>
</div>
</main>
);
}
return (
<main className="min-h-screen px-6 py-10">
<div className="mx-auto max-w-6xl">
{/* Breadcrumb */}
<nav className="flex items-center gap-2 text-xs text-stone-500 mb-6">
<a href="/admin" className="hover:text-stone-600 transition-colors">Admin</a>
<span>/</span>
<span className="text-stone-600">Tour Stops</span>
</nav>
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-stone-950 tracking-tight">Tour Stops</h1>
<p className="mt-2 text-sm text-stone-500">
{adminUser?.brand_id
? "Managing stops for your brand."
: "Manage routes, pickup locations, dates, and cutoff times."}
</p>
<main className="min-h-screen bg-[var(--admin-bg)]">
{/* 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">
<div className="flex items-center gap-2 sm: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">
<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/>
<circle cx="12" cy="10" r="3"/>
</svg>
</div>
<div>
<h1 className="text-2xl sm:text-3xl font-black text-[var(--admin-text-primary)] tracking-tight">Stops & Routes</h1>
<p className="text-xs sm:text-sm text-[var(--admin-text-muted)]">
{adminUser?.brand_id ? "Managing stops for your brand." : "Manage routes, pickup locations, dates, and cutoff times."}
</p>
</div>
</div>
<StopsHeaderActions brandId={adminUser?.brand_id ?? ""} />
</div>
<div className="mt-8 overflow-hidden rounded-2xl bg-white border border-stone-200 shadow-sm">
{/* Breadcrumb */}
<div className="flex items-center gap-2 mt-4 text-xs sm:text-sm">
<a href="/admin" className="text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)]">Admin</a>
<span className="text-[var(--admin-text-muted)]">/</span>
<span className="text-[var(--admin-text-primary)] font-medium">Stops & Routes</span>
</div>
</div>
{/* Content */}
<div className="px-4 sm:px-6 md:px-8 py-4 sm:py-6">
<div className="overflow-hidden rounded-2xl border border-[var(--admin-border)] bg-white shadow-sm">
<StopTableClient stops={stops ?? []} />
</div>
</div>
+17 -10
View File
@@ -1,5 +1,6 @@
import TimeTrackingAdminPanel from "@/components/admin/TimeTrackingAdminPanel";
import { getAdminUser } from "@/lib/admin-permissions";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
@@ -9,23 +10,29 @@ export const dynamic = "force-dynamic";
export default async function AdminTimeTrackingPage() {
const adminUser = await getAdminUser();
if (!adminUser) redirect("/login");
// Dev session bypass for platform admin
if (!adminUser) {
const cookieStore = await cookies();
const devSession = cookieStore.get("dev_session")?.value;
if (devSession === "platform_admin" || devSession === "brand_admin") {
// Allow access in dev mode
} else {
redirect("/login");
}
}
const isPlatformAdmin = adminUser.role === "platform_admin";
const isTuxedoAdmin = adminUser.role === "brand_admin" && adminUser.brand_id === TUXEDO_BRAND_ID;
const isIRDAdmin = adminUser.role === "brand_admin" && adminUser.brand_id === IRD_BRAND_ID;
const isPlatformAdmin = adminUser?.role === "platform_admin";
const isTuxedoAdmin = adminUser?.role === "brand_admin" && adminUser?.brand_id === TUXEDO_BRAND_ID;
const isIRDAdmin = adminUser?.role === "brand_admin" && adminUser?.brand_id === IRD_BRAND_ID;
if (!isPlatformAdmin && !isTuxedoAdmin && !isIRDAdmin) {
redirect("/admin/pickup");
}
const effectiveBrandId = adminUser.brand_id ?? TUXEDO_BRAND_ID;
const effectiveBrandId = adminUser?.brand_id ?? TUXEDO_BRAND_ID;
return (
<main className="min-h-screen px-6 py-10">
<div className="mx-auto max-w-6xl">
<TimeTrackingAdminPanel brandId={effectiveBrandId} />
</div>
</main>
<TimeTrackingAdminPanel brandId={effectiveBrandId} />
);
}
@@ -1,5 +1,5 @@
import { redirect } from "next/navigation";
export default function TimeTrackingSettingsRedirect() {
redirect("/admin/settings#workers");
}
redirect("/admin/time-tracking");
}
@@ -0,0 +1,204 @@
import { svcHeaders } from "@/lib/svc-headers";
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const SUPABASE_PAT = process.env.SUPABASE_PAT!;
interface LotRow {
lot_id: string;
lot_number: string;
crop_type: string;
variety: string | null;
harvest_date: string;
field_location: string | null;
field_block: string | null;
worker_name: string | null;
packer_name: string | null;
quantity_lbs: number | null;
quantity_used_lbs: number | null;
yield_estimate_lbs: number | null;
yield_unit: string | null;
bin_id: string | null;
status: string;
}
interface EventRow {
lot_id: string;
event_type: string;
event_time: string;
location: string | null;
notes: string | null;
bin_id: string | null;
created_by_name: string | null;
}
async function adminFetchJson(endpoint: string, body: unknown) {
const res = await fetch(`${SUPABASE_URL}/rest/v1/rpc/${endpoint}`, {
method: "POST",
headers: {
...svcHeaders(SUPABASE_PAT),
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (!res.ok) return null;
return res.json();
}
function assessCompliance(lot: LotRow, eventCount: number): {
compliance_status: "compliant" | "non_compliant" | "pending";
issues: string[];
} {
const issues: string[] = [];
// Check for required traceability fields
if (!lot.harvest_date) {
issues.push("Missing harvest date");
}
if (!lot.field_location) {
issues.push("Missing field location");
}
if (!lot.quantity_lbs || lot.quantity_lbs <= 0) {
issues.push("Missing quantity");
}
// Check traceability chain
if (eventCount === 0) {
issues.push("No trace events");
}
// Check for worker/packer info
if (!lot.worker_name && !lot.packer_name) {
issues.push("No worker/packer info");
}
// Check yield variance
if (lot.yield_estimate_lbs && lot.yield_estimate_lbs > 0 && lot.quantity_lbs) {
const variance = Math.abs((lot.quantity_lbs - lot.yield_estimate_lbs) / lot.yield_estimate_lbs);
if (variance > 0.2) {
issues.push("High yield variance");
}
}
// Determine compliance status
let compliance_status: "compliant" | "non_compliant" | "pending";
if (issues.length === 0) {
compliance_status = "compliant";
} else if (issues.some(i => i === "No trace events" || i === "Missing harvest date")) {
compliance_status = "non_compliant";
} else {
compliance_status = "pending";
}
return { compliance_status, issues };
}
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const brandId = searchParams.get("brandId");
const startDate = searchParams.get("startDate");
const endDate = searchParams.get("endDate");
if (!brandId || !startDate || !endDate) {
return new Response(JSON.stringify({ error: "Missing brandId, startDate, or endDate" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
// Fetch all lots for the brand
const allLots = await adminFetchJson("get_harvest_lots", {
p_brand_id: brandId,
p_status: null,
});
const lots: LotRow[] = (allLots ?? []) as LotRow[];
// Filter by date range
const filteredLots = lots.filter((lot) => {
const harvestDate = lot.harvest_date;
if (!harvestDate) return false;
return harvestDate >= startDate && harvestDate <= endDate;
});
if (filteredLots.length === 0) {
return new Response(
JSON.stringify({
lots: [],
summary: {
total_lots: 0,
compliant: 0,
non_compliant: 0,
pending: 0,
total_weight: 0,
used_weight: 0,
remaining_weight: 0,
crops: [],
},
}),
{ headers: { "Content-Type": "application/json" } }
);
}
// Fetch events for all lots
const lotIds = filteredLots.map((l) => l.lot_id);
const eventsRaw = await adminFetchJson("get_events_for_lots", { p_lot_ids: lotIds });
const allEvents: EventRow[] = (eventsRaw ?? []) as EventRow[];
// Group events by lot
const eventsByLot: Record<string, EventRow[]> = {};
for (const evt of allEvents) {
if (!eventsByLot[evt.lot_id]) eventsByLot[evt.lot_id] = [];
eventsByLot[evt.lot_id].push(evt);
}
// Process each lot for compliance
const complianceLots = filteredLots.map((lot) => {
const events = eventsByLot[lot.lot_id] ?? [];
const { compliance_status, issues } = assessCompliance(lot, events.length);
return {
lot_id: lot.lot_id,
lot_number: lot.lot_number,
crop_type: lot.crop_type,
variety: lot.variety,
harvest_date: lot.harvest_date,
field_location: lot.field_location,
field_block: lot.field_block,
worker_name: lot.worker_name,
packer_name: lot.packer_name,
quantity_lbs: lot.quantity_lbs,
quantity_used_lbs: lot.quantity_used_lbs,
yield_estimate_lbs: lot.yield_estimate_lbs,
yield_unit: lot.yield_unit,
bin_id: lot.bin_id,
status: lot.status,
event_count: events.length,
has_traceability: events.length > 0,
compliance_status,
issues,
};
});
// Calculate summary
const summary = {
total_lots: complianceLots.length,
compliant: complianceLots.filter((l) => l.compliance_status === "compliant").length,
non_compliant: complianceLots.filter((l) => l.compliance_status === "non_compliant").length,
pending: complianceLots.filter((l) => l.compliance_status === "pending").length,
total_weight: filteredLots.reduce((s, l) => s + (l.quantity_lbs ?? 0), 0),
used_weight: filteredLots.reduce((s, l) => s + (l.quantity_used_lbs ?? 0), 0),
remaining_weight: filteredLots.reduce(
(s, l) => s + Math.max(0, (l.quantity_lbs ?? 0) - (l.quantity_used_lbs ?? 0)),
0
),
crops: [...new Set(filteredLots.map((l) => l.crop_type))],
};
return new Response(
JSON.stringify({
lots: complianceLots,
summary,
}),
{ headers: { "Content-Type": "application/json" } }
);
}
+20 -13
View File
@@ -54,18 +54,25 @@ export async function GET(request: Request) {
return new Response("Missing brandId, startDate, or endDate", { status: 400 });
}
const lotsRaw = await adminFetchJson("get_lots_for_period", {
// Use existing get_harvest_lots RPC and filter by date in JavaScript
const allLots = await adminFetchJson("get_harvest_lots", {
p_brand_id: brandId,
p_start_date: startDate,
p_end_date: endDate,
p_status: null,
});
const lots: LotRow[] = (lotsRaw ?? []) as LotRow[];
if (!lots || lots.length === 0) {
const lots: LotRow[] = (allLots ?? []) as LotRow[];
// Filter by date range
const filteredLots = lots.filter(lot => {
const harvestDate = lot.harvest_date;
if (!harvestDate) return false;
return harvestDate >= startDate && harvestDate <= endDate;
});
if (filteredLots.length === 0) {
return new Response("No lots found for this period", { status: 404 });
}
const lotIds = lots.map((l) => l.lot_id);
const lotIds = filteredLots.map((l) => l.lot_id);
const eventsRaw = await adminFetchJson("get_events_for_lots", { p_lot_ids: lotIds });
const allEvents: EventRow[] = (eventsRaw ?? []) as EventRow[];
@@ -81,16 +88,16 @@ export async function GET(request: Request) {
lines.push(`Brand ID,${brandId}`);
lines.push(`Report Period,${startDate} to ${endDate}`);
lines.push(`Generated,${new Date().toLocaleString("en-US")}`);
lines.push(`Total Lots,${lots.length}`);
lines.push(`Total Lots,${filteredLots.length}`);
lines.push("");
const totalQty = lots.reduce((s, l) => s + (l.quantity_lbs ?? 0), 0);
const totalUsed = lots.reduce((s, l) => s + (l.quantity_used_lbs ?? 0), 0);
const crops = [...new Set(lots.map((l) => l.crop_type))];
const units = [...new Set(lots.map((l) => l.yield_unit ?? "lbs"))];
const totalQty = filteredLots.reduce((s, l) => s + (l.quantity_lbs ?? 0), 0);
const totalUsed = filteredLots.reduce((s, l) => s + (l.quantity_used_lbs ?? 0), 0);
const crops = [...new Set(filteredLots.map((l) => l.crop_type))];
const units = [...new Set(filteredLots.map((l) => l.yield_unit ?? "lbs"))];
const primaryUnit = units.length === 1 ? units[0] : "lbs";
lines.push("SUMMARY");
lines.push(`Total Lots Harvested,${lots.length}`);
lines.push(`Total Lots Harvested,${filteredLots.length}`);
lines.push(`Total Weight (${primaryUnit}),${totalQty.toLocaleString()}`);
lines.push(`Total Used (${primaryUnit}),${totalUsed.toLocaleString()}`);
lines.push(`Remaining (${primaryUnit}),${(totalQty - totalUsed).toLocaleString()}`);
@@ -102,7 +109,7 @@ export async function GET(request: Request) {
lines.push("ONE-UP / ONE-DOWN TRACEABILITY");
lines.push("Lot Number,Crop,Variety,Harvest Date,Field,Block,Worker,Packer,Qty (lbs),Used (lbs),Remaining (lbs),Yield Est. (lbs),Yield Unit,Yield Variance %,Bin ID,Status,Event,Event Time,Location,Bin ID,Notes,Recorded By");
for (const lot of lots) {
for (const lot of filteredLots) {
const evts = eventsByLot[lot.lot_id] ?? [];
const remaining = (lot.quantity_lbs ?? 0) - (lot.quantity_used_lbs ?? 0);
if (evts.length === 0) {
+101
View File
@@ -0,0 +1,101 @@
import { NextResponse } from "next/server";
import { getAdminUser } from "@/lib/admin-permissions";
import { savePaymentSettings } from "@/actions/payments";
export async function GET(req: Request) {
const adminUser = await getAdminUser();
if (!adminUser || !adminUser.brand_id) {
return NextResponse.redirect(new URL("/admin/settings/payments?error=unauthorized", req.url));
}
const url = new URL(req.url);
const code = url.searchParams.get("code");
const error = url.searchParams.get("error");
const state = url.searchParams.get("state");
// Handle error from Stripe
if (error) {
return NextResponse.redirect(
new URL(`/admin/settings/payments?stripe_error=${encodeURIComponent(error)}`, req.url)
);
}
if (!code) {
return NextResponse.redirect(
new URL("/admin/settings/payments?stripe_error=no_code", req.url)
);
}
// Decode state to get brandId
let brandId = adminUser.brand_id;
if (state) {
try {
const decoded = JSON.parse(Buffer.from(state, "base64").toString());
if (decoded.brandId) {
brandId = decoded.brandId;
}
} catch {}
}
// Exchange code for access token
const clientId = process.env.STRIPE_CLIENT_ID;
const clientSecret = process.env.STRIPE_CLIENT_SECRET;
if (!clientId || !clientSecret) {
return NextResponse.redirect(
new URL("/admin/settings/payments?stripe_error=oauth_not_configured", req.url)
);
}
try {
// Exchange authorization code for access token
const tokenResponse = await fetch("https://connect.stripe.com/oauth/token", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
grant_type: "authorization_code",
code,
client_id: clientId,
client_secret: clientSecret,
}),
});
const tokenData = await tokenResponse.json();
if (tokenData.error) {
return NextResponse.redirect(
new URL(`/admin/settings/payments?stripe_error=${encodeURIComponent(tokenData.error_description || tokenData.error)}`, req.url)
);
}
// Save the Stripe credentials
const stripeUserId = tokenData.stripe_user_id; // This is the connected account ID
const accessToken = tokenData.access_token;
const publishableKey = tokenData.stripe_publishable_key;
// Save to database via server action
const result = await savePaymentSettings({
brandId,
provider: "stripe",
stripePublishableKey: publishableKey || undefined,
stripeSecretKey: accessToken, // Store access token as secret key
stripeUserId, // Store the connected account ID
});
if (result.success) {
return NextResponse.redirect(
new URL("/admin/settings/payments?stripe_connected=true", req.url)
);
} else {
return NextResponse.redirect(
new URL(`/admin/settings/payments?stripe_error=${encodeURIComponent(result.error || "Failed to save")}`, req.url)
);
}
} catch (err) {
return NextResponse.redirect(
new URL(`/admin/settings/payments?stripe_error=${encodeURIComponent("Failed to complete OAuth")}`, req.url)
);
}
}
+42
View File
@@ -0,0 +1,42 @@
import { NextResponse } from "next/server";
import { getAdminUser } from "@/lib/admin-permissions";
export async function GET(req: Request) {
const adminUser = await getAdminUser();
if (!adminUser || !adminUser.brand_id) {
return NextResponse.redirect(new URL("/admin/settings/payments?error=unauthorized", req.url));
}
if (!adminUser.can_manage_settings) {
return NextResponse.redirect(new URL("/admin/settings/payments?error=forbidden", req.url));
}
const clientId = process.env.STRIPE_CLIENT_ID;
const clientSecret = process.env.STRIPE_CLIENT_SECRET;
const env = process.env.STRIPE_ENVIRONMENT ?? "test";
if (!clientId || !clientSecret) {
return NextResponse.redirect(
new URL("/admin/settings/payments?error=stripe_oauth_not_configured", req.url)
);
}
const baseUrl = env === "production"
? "https://connect.stripe.com"
: "https://connect.stripe.com/oauth/authorize";
const origin = new URL(req.url).origin;
const redirectUri = `${origin}/api/stripe/oauth/callback`;
// Encode brand_id in state so callback knows which brand to associate
const state = Buffer.from(JSON.stringify({ brandId: adminUser.brand_id })).toString("base64");
const params = new URLSearchParams({
response_type: "code",
client_id: clientId,
scope: "read_only", // Use read_only for safety - can upgrade to full later
redirect_uri: redirectUri,
state,
});
return NextResponse.redirect(`${baseUrl}?${params}`);
}
+15
View File
@@ -3,6 +3,21 @@ import { getTraceChain } from "@/actions/route-trace/lots";
import ShareTraceButton from "@/components/route-trace/ShareTraceButton";
import { svcHeaders } from "@/lib/svc-headers";
export async function generateMetadata({ params }: { params: Promise<{ lotNumber: string }> }) {
const { lotNumber } = await params;
return {
title: `Lot ${lotNumber} | Fresh Produce Traceability`,
description: `View the complete traceability record for harvest lot ${lotNumber}. Track fresh produce from field to delivery with full supply chain transparency.`,
keywords: [`lot ${lotNumber}`, "produce traceability", "farm to fork", "harvest lot", "fresh produce", "food safety", "supply chain"],
openGraph: {
title: `Lot ${lotNumber} | Fresh Produce Traceability`,
description: `Complete farm-to-fork traceability record. View harvest details, supply chain events, and delivery information for lot ${lotNumber}.`,
type: "website",
},
};
}
const EVENT_ICONS: Record<string, string> = {
harvested: "🌱",
field_packed: "📦",