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:
parent
ca8773d3c3
commit
fca6e8da0a
@ -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' })}
|
||||
|
||||
50
estore/src/app/api/admin/orders/[orderId]/status/route.ts
Normal file
50
estore/src/app/api/admin/orders/[orderId]/status/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
@ -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',
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user