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 ─────────────────────────────────────────────────────────────
interface AdminOrder {
id: string
state: string
createdAt: string
customerName: string
customerPhone: string
address: string | null
type: 'delivery' | 'pickup'
slotISO: string | null
lineItems: { name: string; quantity: string; note: string | null }[]
totalCents: number | null
version: number
id: string
state: string
fulfillmentState: string
createdAt: string
customerName: string
customerPhone: string
address: string | null
type: 'delivery' | 'pickup'
slotISO: string | null
lineItems: { name: string; quantity: string; note: string | null }[]
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 {
@ -778,11 +786,11 @@ function fmtSlot(iso: string): string {
}
function OrdersPanel() {
const [orders, setOrders] = useState<AdminOrder[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [completing, setCompleting] = useState<string | null>(null)
const [completeMsg, setCompleteMsg] = useState<Record<string, string>>({})
const [orders, setOrders] = useState<AdminOrder[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [updating, setUpdating] = useState<string | null>(null)
const [updateMsg, setUpdateMsg] = useState<Record<string, string>>({})
const load = useCallback(async () => {
setLoading(true)
@ -801,22 +809,29 @@ function OrdersPanel() {
useEffect(() => { load() }, [load])
async function handleComplete(orderId: string) {
if (!confirm('Mark this order as complete in Square?')) return
setCompleting(orderId)
async function handleSetState(orderId: string, fulfillmentState: string) {
setUpdating(orderId)
setUpdateMsg((prev) => ({ ...prev, [orderId]: '' }))
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()
if (res.ok) {
setOrders((prev) => prev.filter((o) => o.id !== orderId))
setCompleteMsg((prev) => ({ ...prev, [orderId]: 'Done' }))
if (fulfillmentState === 'COMPLETED') {
setOrders((prev) => prev.filter((o) => o.id !== orderId))
} else {
setOrders((prev) => prev.map((o) => o.id === orderId ? { ...o, fulfillmentState } : o))
}
} else {
setCompleteMsg((prev) => ({ ...prev, [orderId]: data.error ?? 'Failed' }))
setUpdateMsg((prev) => ({ ...prev, [orderId]: data.error ?? 'Failed' }))
}
} catch {
setCompleteMsg((prev) => ({ ...prev, [orderId]: 'Network error' }))
setUpdateMsg((prev) => ({ ...prev, [orderId]: 'Network error' }))
} finally {
setCompleting(null)
setUpdating(null)
}
}
@ -883,16 +898,51 @@ function OrdersPanel() {
{order.totalCents != null && (
<strong style={{ fontSize: '0.9rem' }}>${(order.totalCents / 100).toFixed(2)}</strong>
)}
<button
className={`button is-small is-success${completing === order.id ? ' is-loading' : ''}`}
disabled={completing === order.id}
onClick={() => handleComplete(order.id)}
type="button"
>
Mark complete
</button>
{completeMsg[order.id] && (
<span className="is-size-7 has-text-grey">{completeMsg[order.id]}</span>
{/* Status badge */}
{(() => {
const s = FULFILLMENT_LABELS[order.fulfillmentState] ?? { label: order.fulfillmentState, color: '#888' }
return (
<span style={{ fontSize: '0.72rem', fontWeight: 600, color: s.color, letterSpacing: '0.02em' }}>
{s.label}
</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">
{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,
type: type === 'DELIVERY' ? 'delivery' : 'pickup',
slotISO,
fulfillmentState: fulfillment?.state ?? 'PROPOSED',
lineItems: (o.lineItems ?? []).map((li) => ({
name: li.name ?? '',
quantity: li.quantity ?? '1',