Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
181 changes: 133 additions & 48 deletions src/app/admin/orders/page.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
'use client';

import React, { useState, useEffect, Suspense } from 'react';
import React, { useState, useEffect, useMemo, Suspense } from 'react';
import { useSearchParams } from 'next/navigation';
import { OrderDetailDrawer } from '../../../components/admin/OrderDetailDrawer';
import { ShoppingCart, Search, CheckCircle2, Truck, Mail, Archive, AlertCircle, RefreshCw } from 'lucide-react';
import { ShoppingCart, Search, CheckCircle2, Truck, Mail, Archive, AlertCircle, RefreshCw, Bell } from 'lucide-react';
import { classifyFulfillmentQueue, FULFILLMENT_QUEUES } from '@/lib/admin/order-classification.mjs';

interface OrderItem {
id: string;
Expand Down Expand Up @@ -42,11 +43,17 @@ interface TableColumn {
className?: string;
}

const QUEUE_TABS = [
{ id: 'ALL', label: 'ALL' },
...FULFILLMENT_QUEUES,
{ id: 'AWAITING_PAYMENT', label: 'AWAITING PAYMENT' },
];

function AdminOrdersPageInner() {
const [orders, setOrders] = useState<Order[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [statusFilter, setStatusFilter] = useState('ALL');
const [queueFilter, setQueueFilter] = useState('ALL');
const [actionMessage, setActionMessage] = useState('');
const [editingTrackingId, setEditingTrackingId] = useState<string | null>(null);
const [trackingInput, setTrackingInput] = useState('');
Expand Down Expand Up @@ -135,30 +142,69 @@ function AdminOrdersPageInner() {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ orderId, emailType: 'order' }),
});
setActionMessage('Order verification email resent');
setActionMessage('Customer verification email resent');
} catch (err) {
console.error('[admin/orders/resend-email] error:', err);
setActionMessage('Failed to resend email');
}
setTimeout(() => setActionMessage(''), 3000);
};

const filteredOrders = orders.filter(o => {
const matchSearch =
o.order_number.toLowerCase().includes(searchQuery.toLowerCase()) ||
o.customer_name.toLowerCase().includes(searchQuery.toLowerCase()) ||
o.customer_email.toLowerCase().includes(searchQuery.toLowerCase());
const matchStatus = statusFilter === 'ALL' || o.status.toLowerCase() === statusFilter.toLowerCase();
return matchSearch && matchStatus;
});
const handleResendOwnerEmail = async (orderId: string) => {
try {
const res = await fetch('/api/admin/orders/resend-email', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ orderId, emailType: 'owner_alert' }),
});
const data = await res.json();
if (data.success) {
setActionMessage('Owner notification alert resent');
} else {
setActionMessage(data.error || 'Failed to resend owner alert');
}
} catch (err) {
console.error('[admin/orders/resend-owner-email] error:', err);
setActionMessage('Failed to resend owner alert');
}
setTimeout(() => setActionMessage(''), 3000);
};

const queueCounts = useMemo(() => {
const counts: Record<string, number> = { ALL: orders.length };
for (const tab of QUEUE_TABS) {
counts[tab.id] = 0;
}
counts.ALL = orders.length;

for (const o of orders) {
const q = classifyFulfillmentQueue(o);
counts[q] = (counts[q] || 0) + 1;
}
return counts;
}, [orders]);

const filteredOrders = useMemo(() => {
return orders.filter(o => {
const matchSearch =
o.order_number.toLowerCase().includes(searchQuery.toLowerCase()) ||
o.customer_name.toLowerCase().includes(searchQuery.toLowerCase()) ||
o.customer_email.toLowerCase().includes(searchQuery.toLowerCase());
if (!matchSearch) return false;

if (queueFilter === 'ALL') return true;
const q = classifyFulfillmentQueue(o);
return q === queueFilter;
});
}, [orders, searchQuery, queueFilter]);

return (
<div className="space-y-8">

<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 border-b border-white/10 pb-4">
<div>
<h1 className="font-display text-2xl font-bold text-white">Orders & Fulfillment</h1>
<p className="text-xs font-mono text-slate-400">Inspect research procurement orders, update status lifecycles, and issue tracking notices</p>
<p className="text-xs font-mono text-slate-400">Inspect research procurement orders, update status lifecycles, and manage fulfillment queues</p>
</div>
<button
onClick={loadOrders}
Expand All @@ -176,7 +222,33 @@ function AdminOrdersPageInner() {
</div>
)}

{/* Filter Bar */}
{/* Queue Tabs */}
<div className="flex items-center space-x-2 w-full overflow-x-auto pb-1 scrollbar-thin">
{QUEUE_TABS.map(tab => {
const count = queueCounts[tab.id] ?? 0;
const isActive = queueFilter === tab.id;
return (
<button
key={tab.id}
onClick={() => setQueueFilter(tab.id)}
className={`px-3 py-1.5 rounded-lg uppercase text-[10px] font-bold whitespace-nowrap transition-all flex items-center space-x-1.5 ${
isActive
? 'bg-brand-primary text-brand-paper shadow-sm'
: 'bg-slate-900 border border-white/10 text-slate-400 hover:text-white hover:border-white/20'
}`}
>
<span>{tab.label}</span>
<span className={`px-1.5 py-0.2 rounded-full text-[9px] ${
isActive ? 'bg-white/20 text-white font-mono' : 'bg-slate-800 text-slate-400 font-mono'
}`}>
{count}
</span>
</button>
);
})}
</div>

{/* Filter / Search Bar */}
<div className="flex flex-col sm:flex-row gap-4 justify-between items-center text-xs font-mono">
<div className="relative w-full sm:w-80">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
Expand All @@ -188,22 +260,6 @@ function AdminOrdersPageInner() {
className="w-full pl-9 pr-3 py-2 rounded-xl bg-slate-900 border border-white/10 text-white placeholder-slate-500 focus:outline-none focus:border-brand-graphite"
/>
</div>

<div className="flex items-center space-x-2 w-full sm:w-auto overflow-x-auto">
{['ALL', 'new', 'pending_payment', 'paid', 'preparing', 'shipped', 'fulfilled', 'canceled', 'refunded'].map(st => (
<button
key={st}
onClick={() => setStatusFilter(st)}
className={`px-3 py-1.5 rounded-lg uppercase text-[10px] font-bold whitespace-nowrap transition-all ${
statusFilter === st
? 'bg-brand-primary text-brand-paper'
: 'bg-slate-900 border border-white/10 text-slate-400 hover:text-white'
}`}
>
{st}
</button>
))}
</div>
</div>

{/* Orders Table */}
Expand All @@ -216,7 +272,7 @@ function AdminOrdersPageInner() {
<th className="p-4">Line Items</th>
<th className="p-4">Total Due</th>
<th className="p-4">Payment</th>
<th className="p-4">Status</th>
<th className="p-4">Queue & Status</th>
<th className="p-4">Tracking</th>
<th className="p-4 text-right">Actions</th>
</tr>
Expand Down Expand Up @@ -251,21 +307,43 @@ function AdminOrdersPageInner() {
)}
</td>
<td className="p-4">
<select
value={o.status}
onChange={(e) => handleUpdateStatus(o.id, e.target.value)}
className="bg-slate-900 border border-white/15 rounded px-2 py-1 text-[10px] font-bold text-white focus:outline-none focus:border-brand-graphite"
>
<option value="new">new</option>
<option value="invoice_sent">invoice_sent</option>
<option value="pending_payment">pending_payment</option>
<option value="paid">paid</option>
<option value="preparing">preparing</option>
<option value="shipped">shipped</option>
<option value="fulfilled">fulfilled</option>
<option value="canceled">canceled</option>
<option value="refunded">refunded</option>
</select>
{(() => {
const q = classifyFulfillmentQueue(o);
return (
<div className="space-y-1.5">
<span className={`inline-block px-2 py-0.5 rounded text-[9px] font-bold font-mono tracking-wider ${
q === 'PAID_NEEDS_FULFILLMENT' ? 'bg-emerald-950 text-emerald-400 border border-emerald-800/60' :
q === 'WAITING_ON_INVENTORY' ? 'bg-amber-950 text-amber-300 border border-amber-800/60' :
q === 'WAITING_ON_DOCUMENTATION' ? 'bg-purple-950 text-purple-300 border border-purple-800/60' :
q === 'SHIPPED' ? 'bg-sky-950 text-sky-300 border border-sky-800/60' :
q === 'DELIVERED_COMPLETED' ? 'bg-slate-800 text-slate-300 border border-slate-700' :
q === 'REFUND_EXCEPTION' ? 'bg-rose-950 text-rose-300 border border-rose-800/60' :
'bg-slate-900 text-slate-400 border border-white/10'
}`}>
{q.replace(/_/g, ' ')}
</span>
<div>
<select
value={o.status}
onChange={(e) => handleUpdateStatus(o.id, e.target.value)}
className="bg-slate-900 border border-white/15 rounded px-2 py-1 text-[10px] font-bold text-white focus:outline-none focus:border-brand-graphite"
>
<option value="new">new</option>
<option value="invoice_sent">invoice_sent</option>
<option value="pending_payment">pending_payment</option>
<option value="paid">paid</option>
<option value="waiting_inventory">waiting_inventory</option>
<option value="waiting_documentation">waiting_documentation</option>
<option value="preparing">preparing</option>
<option value="shipped">shipped</option>
<option value="fulfilled">fulfilled</option>
<option value="canceled">canceled</option>
<option value="refunded">refunded</option>
</select>
</div>
</div>
);
})()}
</td>
<td className="p-4">
{editingTrackingId === o.id ? (
Expand Down Expand Up @@ -301,14 +379,21 @@ function AdminOrdersPageInner() {
</div>
)}
</td>
<td className="p-4 text-right space-x-2">
<td className="p-4 text-right space-x-1.5 whitespace-nowrap">
<button
onClick={() => handleResendOrderEmail(o.id)}
title="Resend Verification Email"
title="Resend Customer Verification Email"
className="p-1.5 rounded bg-slate-900 border border-white/10 hover:border-brand-graphite text-brand-paper"
>
<Mail className="w-3.5 h-3.5" />
</button>
<button
onClick={() => handleResendOwnerEmail(o.id)}
title="Resend Owner Alert Email"
className="p-1.5 rounded bg-slate-900 border border-white/10 hover:border-brand-graphite text-brand-paper"
>
<Bell className="w-3.5 h-3.5" />
</button>
</td>
</tr>
))}
Expand Down
81 changes: 75 additions & 6 deletions src/app/api/admin/orders/resend-email/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,15 @@ import { createAdminClient } from "@/lib/supabase/admin";
import { sendEmailSafely } from "@/lib/email/resend";
import { renderOrderConfirmationEmail } from "@/lib/email/templates/order";
import { renderTrackingUpdateEmail } from "@/lib/email/templates/tracking";
import { renderInternalOrderNotificationEmail } from "@/lib/email/templates/internal-order";
import { recordOrderEvent } from "@/lib/admin/order-events";
import { getBrandConfig } from "@/config/brand";
import { classifyFulfillmentQueue } from "@/lib/admin/order-classification.mjs";

function adminOrderUrl(orderNumber: string) {
const base = (process.env.NEXT_PUBLIC_SITE_URL || "https://www.vialfoundry.com").replace(/\/$/, "");
return `${base}/admin/orders?order=${encodeURIComponent(orderNumber)}`;
}

export async function POST(req: Request) {
const isAuth = await verifyAdminSession();
Expand All @@ -28,9 +36,64 @@ export async function POST(req: Request) {
}

let sentResult: { success: boolean; error?: string };
const emailLabel = emailType === "tracking" ? "tracking update" : "order confirmation";
let emailLabel: string;
let targetRecipient: string;
let isInternal = false;

if (emailType === "owner_alert") {
isInternal = true;
emailLabel = "owner alert";
const brand = getBrandConfig();
const admins = brand.orderNotificationEmails;
if (!admins.length) {
return NextResponse.json({ success: false, error: "No owner notification emails configured" }, { status: 400 });
}
targetRecipient = admins.join(", ");

if (emailType === "tracking" && order.tracking_number) {
const queue = classifyFulfillmentQueue(order);
const fulfillmentNextStep =
queue === "WAITING_ON_INVENTORY"
? "WAITING ON INVENTORY: Insufficient stock. Replenishment required before packing."
: queue === "WAITING_ON_DOCUMENTATION"
? "WAITING ON DOCUMENTATION: Customer follow-up or documentation needed."
: queue === "PAID_NEEDS_FULFILLMENT"
? "PAID — READY TO PICK AND PACK. Verify items and print shipping label."
: `Current operational queue: ${queue}`;

const email = renderInternalOrderNotificationEmail({
orderNumber: order.order_number,
customerName: order.customer_name,
customerEmail: order.customer_email,
customerPhone: order.customer_phone,
items: (order.manual_order_items || []).map((i: any) => ({
name: i.product_name,
sku: i.sku,
quantity: i.quantity,
unitPriceCents: i.unit_price_amount || 0,
lineTotalCents: i.line_total_amount || 0,
})),
subtotalCents: order.subtotal_amount,
discountCents: order.discount_amount,
shippingCents: order.shipping_amount,
totalCents: order.total_amount,
shippingAddress: order.shipping_address_snapshot || {},
paymentMethod: order.preferred_payment_method || "card (Stripe)",
paymentStatus: order.payment_status || "paid",
fulfillmentNextStep,
promoCode: order.promo_code,
affiliateCode: order.affiliate_code,
isTest: Boolean(order.is_test) || order.stripe_livemode === false,
adminOrderUrl: adminOrderUrl(order.order_number),
});

sentResult = await sendEmailSafely({
to: admins,
subject: `[ADMIN RESEND] ${order.is_test || order.stripe_livemode === false ? "[TEST] " : ""}[PAID] ${order.order_number} — ${order.customer_name} ($${((order.total_amount || 0) / 100).toFixed(2)})`,
html: email.html,
});
} else if (emailType === "tracking" && order.tracking_number) {
emailLabel = "tracking update";
targetRecipient = order.customer_email;
const email = renderTrackingUpdateEmail({
orderNumber: order.order_number,
customerName: order.customer_name,
Expand All @@ -43,11 +106,14 @@ export async function POST(req: Request) {
html: email.html,
});
} else {
emailLabel = "order confirmation";
targetRecipient = order.customer_email;
const email = renderOrderConfirmationEmail({
orderNumber: order.order_number,
customerName: order.customer_name,
items: (order.manual_order_items || []).map((i: any) => ({
name: i.product_name,
sku: i.sku,
quantity: i.quantity,
unitPriceCents: i.unit_price_amount || 0,
lineTotalCents: i.line_total_amount || 0,
Expand All @@ -57,7 +123,7 @@ export async function POST(req: Request) {
shippingCents: order.shipping_amount,
totalCents: order.total_amount,
paymentMethod: order.preferred_payment_method,
paymentState: order.payment_status === 'paid' ? 'paid' : 'awaiting_payment',
paymentState: order.payment_status === "paid" ? "paid" : "awaiting_payment",
shippingAddress: order.shipping_address_snapshot || {},
});

Expand All @@ -73,10 +139,11 @@ export async function POST(req: Request) {
orderId: order.id,
type: 'email_failed',
actor: 'admin-resend',
message: `Admin resend of ${emailLabel} FAILED to ${order.customer_email}: ${sentResult.error}`,
message: `Admin resend of ${emailLabel} FAILED to ${targetRecipient}: ${sentResult.error}`,
metadata: { error: sentResult.error, emailType, targetRecipient },
});
return NextResponse.json(
{ success: false, error: sentResult.error || 'Failed to send email via Resend' },
{ success: false, error: sentResult.error || "Failed to send email via Resend" },
{ status: 502 }
);
}
Expand All @@ -85,7 +152,8 @@ export async function POST(req: Request) {
orderId: order.id,
type: 'email_sent',
actor: 'admin-resend',
message: `Admin resent ${emailLabel} email to ${order.customer_email}`,
message: `Admin resent ${emailLabel} email to ${targetRecipient}`,
metadata: { emailType, targetRecipient },
});

return NextResponse.json({ success: true });
Expand All @@ -94,3 +162,4 @@ export async function POST(req: Request) {
return NextResponse.json({ success: false, error: err.message }, { status: 500 });
}
}

Loading
Loading