PurchaseOrderDetailPage.tsx 10.5 KB
// Purchase-order detail screen — symmetric to SalesOrderDetailPage.
// Confirm a DRAFT, receive a CONFIRMED into a warehouse, or cancel
// either. Each action surfaces the corresponding stock movement
// (PURCHASE_RECEIPT) and pbc-finance journal entry (AP, POSTED →
// SETTLED on receive) inline.

import { useCallback, useEffect, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { finance, inventory, purchaseOrders } from '@/api/client'
import type { JournalEntry, Location, PurchaseOrder, StockMovement } from '@/types/api'
import { PageHeader } from '@/components/PageHeader'
import { Loading } from '@/components/Loading'
import { ErrorBox } from '@/components/ErrorBox'
import { StatusBadge } from '@/components/StatusBadge'

export function PurchaseOrderDetailPage() {
  const { id = '' } = useParams<{ id: string }>()
  const navigate = useNavigate()
  const [order, setOrder] = useState<PurchaseOrder | null>(null)
  const [locations, setLocations] = useState<Location[]>([])
  const [receivingLocation, setReceivingLocation] = useState<string>('')
  const [movements, setMovements] = useState<StockMovement[]>([])
  const [journalEntries, setJournalEntries] = useState<JournalEntry[]>([])
  const [loading, setLoading] = useState(true)
  const [acting, setActing] = useState(false)
  const [error, setError] = useState<Error | null>(null)
  const [actionMessage, setActionMessage] = useState<string | null>(null)

  const reloadSideEffects = useCallback(async (orderCode: string) => {
    const [ms, js] = await Promise.all([
      inventory.listMovements(),
      finance.listJournalEntries(),
    ])
    setMovements(ms.filter((m) => m.reference?.includes(orderCode) ?? false))
    setJournalEntries(js.filter((j) => j.orderCode === orderCode))
  }, [])

  useEffect(() => {
    let active = true
    setLoading(true)
    Promise.all([purchaseOrders.get(id), inventory.listLocations()])
      .then(async ([o, locs]: [PurchaseOrder, Location[]]) => {
        if (!active) return
        setOrder(o)
        setLocations(locs.filter((l) => l.active))
        const firstWarehouse = locs.find((l) => l.active && l.type === 'WAREHOUSE')
        setReceivingLocation(firstWarehouse?.code ?? locs[0]?.code ?? '')
        await reloadSideEffects(o.code)
      })
      .catch((e: unknown) => {
        if (active) setError(e instanceof Error ? e : new Error(String(e)))
      })
      .finally(() => active && setLoading(false))
    return () => {
      active = false
    }
  }, [id, reloadSideEffects])

  if (loading) return <Loading />
  if (error) return <ErrorBox error={error} />
  if (!order) return <ErrorBox error="Purchase order not found" />

  const onConfirm = async () => {
    setActing(true)
    setError(null)
    setActionMessage(null)
    try {
      const updated = await purchaseOrders.confirm(order.id)
      setOrder(updated)
      await reloadSideEffects(updated.code)
      setActionMessage('Confirmed. pbc-finance has posted an AP journal entry.')
    } catch (e: unknown) {
      setError(e instanceof Error ? e : new Error(String(e)))
    } finally {
      setActing(false)
    }
  }

  const onReceive = async () => {
    if (!receivingLocation) {
      setError(new Error('Pick a receiving location first.'))
      return
    }
    setActing(true)
    setError(null)
    setActionMessage(null)
    try {
      const updated = await purchaseOrders.receive(order.id, receivingLocation)
      setOrder(updated)
      await reloadSideEffects(updated.code)
      setActionMessage(
        `Received into ${receivingLocation}. Stock credited, journal entry settled.`,
      )
    } catch (e: unknown) {
      setError(e instanceof Error ? e : new Error(String(e)))
    } finally {
      setActing(false)
    }
  }

  const onCancel = async () => {
    setActing(true)
    setError(null)
    setActionMessage(null)
    try {
      const updated = await purchaseOrders.cancel(order.id)
      setOrder(updated)
      await reloadSideEffects(updated.code)
      setActionMessage('Cancelled. pbc-finance has reversed any open journal entry.')
    } catch (e: unknown) {
      setError(e instanceof Error ? e : new Error(String(e)))
    } finally {
      setActing(false)
    }
  }

  const canConfirm = order.status === 'DRAFT'
  const canReceive = order.status === 'CONFIRMED'
  const canCancel = order.status === 'DRAFT' || order.status === 'CONFIRMED'

  return (
    <div>
      <PageHeader
        title={`Purchase Order ${order.code}`}
        subtitle={`Supplier ${order.partnerCode} · ${order.orderDate} · ${order.currencyCode}`}
        actions={
          <button className="btn-secondary" onClick={() => navigate('/purchase-orders')}>
            ← Back
          </button>
        }
      />

      <div className="card mb-6 p-5">
        <div className="flex flex-wrap items-center justify-between gap-4">
          <div className="flex items-center gap-3">
            <span className="text-sm text-slate-500">Status:</span>
            <StatusBadge status={order.status} />
          </div>
          <div className="text-right">
            <div className="text-xs uppercase text-slate-400">Total</div>
            <div className="font-mono text-xl font-semibold">
              {Number(order.totalAmount).toLocaleString(undefined, {
                minimumFractionDigits: 2,
              })}{' '}
              {order.currencyCode}
            </div>
          </div>
        </div>
        {actionMessage && (
          <div className="mt-4 rounded-md border border-emerald-200 bg-emerald-50 px-4 py-2 text-sm text-emerald-800">
            {actionMessage}
          </div>
        )}
      </div>

      <div className="card mb-6 p-5">
        <h2 className="mb-3 text-base font-semibold text-slate-800">Actions</h2>
        <div className="flex flex-wrap items-center gap-3">
          <button className="btn-primary" disabled={!canConfirm || acting} onClick={onConfirm}>
            Confirm
          </button>
          <div className="flex items-center gap-2">
            <select
              className="rounded-md border border-slate-300 px-2 py-1 text-sm shadow-sm focus:border-brand-500 focus:ring-brand-500"
              value={receivingLocation}
              onChange={(e) => setReceivingLocation(e.target.value)}
              disabled={!canReceive || acting}
            >
              {locations.map((l) => (
                <option key={l.id} value={l.code}>
                  {l.code} — {l.name}
                </option>
              ))}
            </select>
            <button className="btn-primary" disabled={!canReceive || acting} onClick={onReceive}>
              Receive
            </button>
          </div>
          <button className="btn-danger" disabled={!canCancel || acting} onClick={onCancel}>
            Cancel
          </button>
        </div>
      </div>

      <div className="card mb-6">
        <div className="border-b border-slate-200 px-5 py-3">
          <h2 className="text-base font-semibold text-slate-800">Lines</h2>
        </div>
        <table className="table-base">
          <thead className="bg-slate-50">
            <tr>
              <th>#</th>
              <th>Item</th>
              <th>Qty</th>
              <th>Unit price</th>
              <th>Line total</th>
            </tr>
          </thead>
          <tbody className="divide-y divide-slate-100">
            {order.lines.map((l) => (
              <tr key={l.id}>
                <td>{l.lineNo}</td>
                <td className="font-mono">{l.itemCode}</td>
                <td className="font-mono tabular-nums">{String(l.quantity)}</td>
                <td className="font-mono tabular-nums">
                  {Number(l.unitPrice).toLocaleString(undefined, {
                    minimumFractionDigits: 2,
                  })}{' '}
                  {l.currencyCode}
                </td>
                <td className="font-mono tabular-nums">
                  {Number(l.lineTotal).toLocaleString(undefined, {
                    minimumFractionDigits: 2,
                  })}
                </td>
              </tr>
            ))}
          </tbody>
        </table>
      </div>

      <div className="grid gap-6 md:grid-cols-2">
        <div className="card">
          <div className="border-b border-slate-200 px-5 py-3">
            <h2 className="text-base font-semibold text-slate-800">Inventory movements</h2>
          </div>
          {movements.length === 0 ? (
            <div className="p-5 text-sm text-slate-400">No movements yet.</div>
          ) : (
            <table className="table-base">
              <thead className="bg-slate-50">
                <tr>
                  <th>Item</th>
                  <th>Δ</th>
                  <th>Reason</th>
                  <th>Reference</th>
                </tr>
              </thead>
              <tbody className="divide-y divide-slate-100">
                {movements.map((m) => (
                  <tr key={m.id}>
                    <td className="font-mono">{m.itemCode}</td>
                    <td className="font-mono tabular-nums text-emerald-600">{String(m.delta)}</td>
                    <td>{m.reason}</td>
                    <td className="font-mono text-xs text-slate-500">{m.reference ?? '—'}</td>
                  </tr>
                ))}
              </tbody>
            </table>
          )}
        </div>
        <div className="card">
          <div className="border-b border-slate-200 px-5 py-3">
            <h2 className="text-base font-semibold text-slate-800">Journal entries</h2>
          </div>
          {journalEntries.length === 0 ? (
            <div className="p-5 text-sm text-slate-400">No entries yet.</div>
          ) : (
            <table className="table-base">
              <thead className="bg-slate-50">
                <tr>
                  <th>Code</th>
                  <th>Type</th>
                  <th>Status</th>
                  <th>Amount</th>
                </tr>
              </thead>
              <tbody className="divide-y divide-slate-100">
                {journalEntries.map((j) => (
                  <tr key={j.id}>
                    <td className="font-mono text-xs">{j.code}</td>
                    <td>{j.type}</td>
                    <td>
                      <StatusBadge status={j.status} />
                    </td>
                    <td className="font-mono tabular-nums">
                      {Number(j.amount).toLocaleString(undefined, { minimumFractionDigits: 2 })}{' '}
                      {j.currencyCode}
                    </td>
                  </tr>
                ))}
              </tbody>
            </table>
          )}
        </div>
      </div>
    </div>
  )
}