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 ─────────────────────────────────────────────────────────────
|
// ─── 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' })}
|
||||||
|
|||||||
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,
|
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',
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user