Add Orders tab to admin panel for managing online orders

Fetches open orders from Square filtered by source=online-shop metadata.
Each order shows customer, fulfillment time/address, items, and total with
a Mark Complete button that updates the order state in Square directly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
chris 2026-06-05 20:03:52 -04:00
parent 02e49ba41b
commit ef38c42e17
3 changed files with 262 additions and 1 deletions

View File

@ -753,6 +753,161 @@ 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
}
function fmtSlot(iso: string): string {
return new Date(iso).toLocaleString('en-US', {
timeZone: 'America/New_York',
month: 'short', day: 'numeric',
hour: 'numeric', minute: '2-digit', hour12: true,
})
}
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 load = useCallback(async () => {
setLoading(true)
setError('')
try {
const res = await fetch(BASE + '/api/admin/orders')
const data = await res.json()
if (!res.ok) { setError(data.error ?? 'Failed to load orders'); return }
setOrders(data.orders)
} catch {
setError('Network error')
} finally {
setLoading(false)
}
}, [])
useEffect(() => { load() }, [load])
async function handleComplete(orderId: string) {
if (!confirm('Mark this order as complete in Square?')) return
setCompleting(orderId)
try {
const res = await fetch(`${BASE}/api/admin/orders/${orderId}/complete`, { method: 'POST' })
const data = await res.json()
if (res.ok) {
setOrders((prev) => prev.filter((o) => o.id !== orderId))
setCompleteMsg((prev) => ({ ...prev, [orderId]: 'Done' }))
} else {
setCompleteMsg((prev) => ({ ...prev, [orderId]: data.error ?? 'Failed' }))
}
} catch {
setCompleteMsg((prev) => ({ ...prev, [orderId]: 'Network error' }))
} finally {
setCompleting(null)
}
}
return (
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: '1rem' }}>
<p className="is-size-7 has-text-grey" style={{ flex: 1 }}>
Open orders placed via the online shop. Marking complete updates the order in Square.
</p>
<button
className={`button is-small${loading ? ' is-loading' : ''}`}
onClick={load}
type="button"
>
<span className="icon is-small"><i className="fas fa-sync" /></span>
<span>Refresh</span>
</button>
</div>
{error && <p className="has-text-danger is-size-7" style={{ marginBottom: '0.75rem' }}>{error}</p>}
{loading ? (
<p className="has-text-grey">Loading orders</p>
) : orders.length === 0 ? (
<p className="has-text-grey">No open online orders.</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{orders.map((order) => (
<div key={order.id} style={{ border: '1px solid #e0e0e0', borderRadius: 8, padding: '0.85rem 1rem', background: '#fff' }}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 10, flexWrap: 'wrap' }}>
{/* Order info */}
<div style={{ flex: 1, minWidth: 200 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<strong style={{ fontSize: '0.9rem' }}>{order.customerName}</strong>
{order.customerPhone && (
<span className="has-text-grey is-size-7">{order.customerPhone}</span>
)}
<span className={`tag is-small ${order.type === 'delivery' ? 'is-info is-light' : 'is-light'}`}>
{order.type === 'delivery' ? '🚗 Delivery' : '🏪 Pickup'}
</span>
</div>
{order.slotISO && (
<p style={{ fontSize: '0.82rem', color: '#444', marginBottom: 2 }}>
{fmtSlot(order.slotISO)}
{order.address && <span className="has-text-grey"> · {order.address}</span>}
</p>
)}
<div style={{ fontSize: '0.8rem', color: '#666', marginTop: 4 }}>
{order.lineItems.map((li, i) => (
<span key={i}>
{i > 0 && ', '}
{li.quantity}× {li.name}
{li.note && <span className="has-text-grey"> ({li.note})</span>}
</span>
))}
</div>
</div>
{/* Right side: total + actions */}
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 6, flexShrink: 0 }}>
{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>
)}
<span className="is-size-7 has-text-grey">
{new Date(order.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
</span>
</div>
</div>
</div>
))}
</div>
)}
</div>
)
}
// ─── Item Editor ──────────────────────────────────────────────────────────────
function ItemEditor({
@ -1550,7 +1705,7 @@ export default function AdminPage() {
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [search, setSearch] = useState('')
const [tab, setTab] = useState<'items' | 'categories' | 'hours' | 'occasions' | 'delivery'>('items')
const [tab, setTab] = useState<'items' | 'categories' | 'hours' | 'occasions' | 'delivery' | 'orders'>('items')
const [fetchedAt, setFetchedAt] = useState<string | null>(null)
const [refreshing, setRefreshing] = useState(false)
const [refreshMsg, setRefreshMsg] = useState('')
@ -1763,6 +1918,9 @@ export default function AdminPage() {
<li className={tab === 'delivery' ? 'is-active' : ''}>
<a onClick={() => setTab('delivery')}>Delivery rates</a>
</li>
<li className={tab === 'orders' ? 'is-active' : ''}>
<a onClick={() => setTab('orders')}>Orders</a>
</li>
</ul>
</div>
@ -1868,6 +2026,9 @@ export default function AdminPage() {
{/* Delivery rates tab */}
{tab === 'delivery' && <DeliveryRatesEditor />}
{/* Orders tab */}
{tab === 'orders' && <OrdersPanel />}
</div>
</section>
)

View File

@ -0,0 +1,39 @@
import { NextRequest, NextResponse } from 'next/server'
import { Client, Environment } from 'square'
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 client = getClient()
const { orderId } = params
// Retrieve current order to get version (required for optimistic concurrency)
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!,
state: 'COMPLETED',
version: order.version,
},
idempotencyKey: `complete-${orderId}`,
})
return NextResponse.json({ ok: true })
} catch (err) {
console.error('[admin/orders/complete]', err)
return NextResponse.json({ error: 'Failed to complete order' }, { status: 500 })
}
}

View File

@ -0,0 +1,61 @@
import { NextResponse } from 'next/server'
import { Client, Environment } from 'square'
function getClient() {
return new Client({
accessToken: process.env.SQUARE_ACCESS_TOKEN!,
environment: process.env.SQUARE_ENVIRONMENT === 'production'
? Environment.Production
: Environment.Sandbox,
})
}
export async function GET() {
try {
const client = getClient()
const locationId = process.env.SQUARE_LOCATION_ID!
const response = await client.ordersApi.searchOrders({
locationIds: [locationId],
query: {
filter: { stateFilter: { states: ['OPEN'] } },
sort: { sortField: 'CREATED_AT', sortOrder: 'DESC' },
},
limit: 100,
})
const orders = (response.result.orders ?? [])
.filter((o) => o.metadata?.source === 'online-shop')
.map((o) => {
const fulfillment = o.fulfillments?.[0]
const pickup = fulfillment?.pickupDetails
const delivery = fulfillment?.deliveryDetails
const recipient = pickup?.recipient ?? delivery?.recipient
const slotISO = pickup?.pickupAt ?? delivery?.deliverAt ?? null
const type = fulfillment?.type ?? 'PICKUP'
return {
id: o.id,
state: o.state,
createdAt: o.createdAt,
customerName: recipient?.displayName ?? '—',
customerPhone: recipient?.phoneNumber ?? '',
address: delivery?.recipient?.address?.addressLine1 ?? null,
type: type === 'DELIVERY' ? 'delivery' : 'pickup',
slotISO,
lineItems: (o.lineItems ?? []).map((li) => ({
name: li.name ?? '',
quantity: li.quantity ?? '1',
note: li.note ?? null,
})),
totalCents: o.totalMoney?.amount != null ? Number(o.totalMoney.amount) : null,
version: o.version ?? 1,
}
})
return NextResponse.json({ orders })
} catch (err) {
console.error('[admin/orders]', err)
return NextResponse.json({ error: 'Failed to fetch orders' }, { status: 500 })
}
}