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:
@@ -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"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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,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
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user