Add fulfillment state controls to admin orders panel

Replace single Mark Complete button with contextual In progress / Ready /
Complete buttons based on current fulfillment state. Adds a general
/api/admin/orders/[orderId]/status endpoint that handles all transitions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
chris 2026-06-06 20:22:23 -04:00
parent ca8773d3c3
commit fca6e8da0a
3 changed files with 136 additions and 35 deletions

View File

@ -756,17 +756,25 @@ function DeliveryRatesEditor() {
// ─── Orders Panel ───────────────────────────────────────────────────────────── // ─── Orders Panel ─────────────────────────────────────────────────────────────
interface AdminOrder { interface AdminOrder {
id: string id: string
state: string state: string
createdAt: string fulfillmentState: string
customerName: string createdAt: string
customerPhone: string customerName: string
address: string | null customerPhone: string
type: 'delivery' | 'pickup' address: string | null
slotISO: string | null type: 'delivery' | 'pickup'
lineItems: { name: string; quantity: string; note: string | null }[] slotISO: string | null
totalCents: number | null lineItems: { name: string; quantity: string; note: string | null }[]
version: number totalCents: number | null
version: number
}
const FULFILLMENT_LABELS: Record<string, { label: string; color: string }> = {
PROPOSED: { label: 'New', color: '#888' },
RESERVED: { label: 'In progress', color: '#d48806' },
PREPARED: { label: 'Ready', color: '#1677ff' },
COMPLETED:{ label: 'Complete', color: '#389e0d' },
} }
function fmtSlot(iso: string): string { function fmtSlot(iso: string): string {
@ -778,11 +786,11 @@ function fmtSlot(iso: string): string {
} }
function OrdersPanel() { function OrdersPanel() {
const [orders, setOrders] = useState<AdminOrder[]>([]) const [orders, setOrders] = useState<AdminOrder[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState('') const [error, setError] = useState('')
const [completing, setCompleting] = useState<string | null>(null) const [updating, setUpdating] = useState<string | null>(null)
const [completeMsg, setCompleteMsg] = useState<Record<string, string>>({}) const [updateMsg, setUpdateMsg] = useState<Record<string, string>>({})
const load = useCallback(async () => { const load = useCallback(async () => {
setLoading(true) setLoading(true)
@ -801,22 +809,29 @@ function OrdersPanel() {
useEffect(() => { load() }, [load]) useEffect(() => { load() }, [load])
async function handleComplete(orderId: string) { async function handleSetState(orderId: string, fulfillmentState: string) {
if (!confirm('Mark this order as complete in Square?')) return setUpdating(orderId)
setCompleting(orderId) setUpdateMsg((prev) => ({ ...prev, [orderId]: '' }))
try { try {
const res = await fetch(`${BASE}/api/admin/orders/${orderId}/complete`, { method: 'POST' }) const res = await fetch(`${BASE}/api/admin/orders/${orderId}/status`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fulfillmentState }),
})
const data = await res.json() const data = await res.json()
if (res.ok) { if (res.ok) {
setOrders((prev) => prev.filter((o) => o.id !== orderId)) if (fulfillmentState === 'COMPLETED') {
setCompleteMsg((prev) => ({ ...prev, [orderId]: 'Done' })) setOrders((prev) => prev.filter((o) => o.id !== orderId))
} else {
setOrders((prev) => prev.map((o) => o.id === orderId ? { ...o, fulfillmentState } : o))
}
} else { } else {
setCompleteMsg((prev) => ({ ...prev, [orderId]: data.error ?? 'Failed' })) setUpdateMsg((prev) => ({ ...prev, [orderId]: data.error ?? 'Failed' }))
} }
} catch { } catch {
setCompleteMsg((prev) => ({ ...prev, [orderId]: 'Network error' })) setUpdateMsg((prev) => ({ ...prev, [orderId]: 'Network error' }))
} finally { } finally {
setCompleting(null) setUpdating(null)
} }
} }
@ -883,16 +898,51 @@ function OrdersPanel() {
{order.totalCents != null && ( {order.totalCents != null && (
<strong style={{ fontSize: '0.9rem' }}>${(order.totalCents / 100).toFixed(2)}</strong> <strong style={{ fontSize: '0.9rem' }}>${(order.totalCents / 100).toFixed(2)}</strong>
)} )}
<button
className={`button is-small is-success${completing === order.id ? ' is-loading' : ''}`} {/* Status badge */}
disabled={completing === order.id} {(() => {
onClick={() => handleComplete(order.id)} const s = FULFILLMENT_LABELS[order.fulfillmentState] ?? { label: order.fulfillmentState, color: '#888' }
type="button" return (
> <span style={{ fontSize: '0.72rem', fontWeight: 600, color: s.color, letterSpacing: '0.02em' }}>
Mark complete {s.label}
</button> </span>
{completeMsg[order.id] && ( )
<span className="is-size-7 has-text-grey">{completeMsg[order.id]}</span> })()}
{/* Action buttons — next logical state(s) only */}
<div style={{ display: 'flex', gap: 4 }}>
{order.fulfillmentState === 'PROPOSED' && (
<button
className={`button is-small is-warning${updating === order.id ? ' is-loading' : ''}`}
disabled={updating === order.id}
onClick={() => handleSetState(order.id, 'RESERVED')}
type="button"
>
In progress
</button>
)}
{(order.fulfillmentState === 'PROPOSED' || order.fulfillmentState === 'RESERVED') && (
<button
className={`button is-small is-info${updating === order.id ? ' is-loading' : ''}`}
disabled={updating === order.id}
onClick={() => handleSetState(order.id, 'PREPARED')}
type="button"
>
Ready
</button>
)}
<button
className={`button is-small is-success${updating === order.id ? ' is-loading' : ''}`}
disabled={updating === order.id}
onClick={() => handleSetState(order.id, 'COMPLETED')}
type="button"
>
Complete
</button>
</div>
{updateMsg[order.id] && (
<span className="is-size-7 has-text-danger">{updateMsg[order.id]}</span>
)} )}
<span className="is-size-7 has-text-grey"> <span className="is-size-7 has-text-grey">
{new Date(order.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })} {new Date(order.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}

View File

@ -0,0 +1,50 @@
import { NextRequest, NextResponse } from 'next/server'
import { Client, Environment } from 'square'
type FulfillmentState = 'RESERVED' | 'PREPARED' | 'COMPLETED'
function getClient() {
return new Client({
accessToken: process.env.SQUARE_ACCESS_TOKEN!,
environment: process.env.SQUARE_ENVIRONMENT === 'production'
? Environment.Production
: Environment.Sandbox,
})
}
export async function POST(
req: NextRequest,
{ params }: { params: { orderId: string } }
) {
try {
const { orderId } = params
const { fulfillmentState } = await req.json() as { fulfillmentState: FulfillmentState }
if (!['RESERVED', 'PREPARED', 'COMPLETED'].includes(fulfillmentState)) {
return NextResponse.json({ error: 'Invalid state' }, { status: 400 })
}
const client = getClient()
const { result: { order } } = await client.ordersApi.retrieveOrder(orderId)
if (!order) return NextResponse.json({ error: 'Order not found' }, { status: 404 })
await client.ordersApi.updateOrder(orderId, {
order: {
locationId: order.locationId!,
version: order.version,
// Only close the order when fulfillment is fully complete
...(fulfillmentState === 'COMPLETED' ? { state: 'COMPLETED' } : {}),
fulfillments: (order.fulfillments ?? []).map((f) => ({
uid: f.uid,
state: fulfillmentState,
})),
},
idempotencyKey: `status-${orderId}-${fulfillmentState}`,
})
return NextResponse.json({ ok: true })
} catch (err) {
console.error('[admin/orders/status]', err)
return NextResponse.json({ error: 'Failed to update order' }, { status: 500 })
}
}

View File

@ -43,6 +43,7 @@ export async function GET() {
address: delivery?.recipient?.address?.addressLine1 ?? null, address: delivery?.recipient?.address?.addressLine1 ?? null,
type: type === 'DELIVERY' ? 'delivery' : 'pickup', type: type === 'DELIVERY' ? 'delivery' : 'pickup',
slotISO, slotISO,
fulfillmentState: fulfillment?.state ?? 'PROPOSED',
lineItems: (o.lineItems ?? []).map((li) => ({ lineItems: (o.lineItems ?? []).map((li) => ({
name: li.name ?? '', name: li.name ?? '',
quantity: li.quantity ?? '1', quantity: li.quantity ?? '1',