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:
parent
02e49ba41b
commit
ef38c42e17
@ -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>
|
||||
)
|
||||
|
||||
39
estore/src/app/api/admin/orders/[orderId]/complete/route.ts
Normal file
39
estore/src/app/api/admin/orders/[orderId]/complete/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
61
estore/src/app/api/admin/orders/route.ts
Normal file
61
estore/src/app/api/admin/orders/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user