SalesOrderDetailPage.tsx 11.6 KB
// Sales-order detail screen.
//
// **The headline demo flow.** Confirm a DRAFT, ship a CONFIRMED,
// or cancel either. Each action updates the order in place,
// reloads the journal entry list to show the AR row appearing
// (POSTED -> SETTLED), and reloads stock movements to show the
// SALES_SHIPMENT ledger entry appearing.
//
// **Why ship asks for a location.** The framework requires every
// shipment to name the warehouse the goods came from -- the
// inventory ledger tags the row with that location and the audit
// trail must always answer "which warehouse shipped this". The UI
// proposes the first WAREHOUSE-typed location it finds; an
// operator can pick another from the dropdown.

import { useCallback, useEffect, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { finance, inventory, salesOrders } from '@/api/client'
import type { JournalEntry, Location, SalesOrder, StockMovement } from '@/types/api'
import { PageHeader } from '@/components/PageHeader'
import { Loading } from '@/components/Loading'
import { ErrorBox } from '@/components/ErrorBox'
import { StatusBadge } from '@/components/StatusBadge'
import { useT } from '@/i18n/LocaleContext'

export function SalesOrderDetailPage() {
  const { id = '' } = useParams<{ id: string }>()
  const navigate = useNavigate()
  const t = useT()
  const [order, setOrder] = useState<SalesOrder | null>(null)
  const [locations, setLocations] = useState<Location[]>([])
  const [shippingLocation, setShippingLocation] = 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([salesOrders.get(id), inventory.listLocations()])
      .then(async ([o, locs]: [SalesOrder, Location[]]) => {
        if (!active) return
        setOrder(o)
        setLocations(locs.filter((l) => l.active))
        const firstWarehouse = locs.find((l) => l.active && l.type === 'WAREHOUSE')
        setShippingLocation(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="Sales order not found" />

  const onConfirm = async () => {
    setActing(true)
    setError(null)
    setActionMessage(null)
    try {
      const updated = await salesOrders.confirm(order.id)
      setOrder(updated)
      await reloadSideEffects(updated.code)
      setActionMessage(t('page.salesOrderDetail.confirmMsg'))
    } catch (e: unknown) {
      setError(e instanceof Error ? e : new Error(String(e)))
    } finally {
      setActing(false)
    }
  }

  const onShip = async () => {
    if (!shippingLocation) {
      setError(new Error(t('page.salesOrderDetail.pickLocation')))
      return
    }
    setActing(true)
    setError(null)
    setActionMessage(null)
    try {
      const updated = await salesOrders.ship(order.id, shippingLocation)
      setOrder(updated)
      await reloadSideEffects(updated.code)
      setActionMessage(
        t('page.salesOrderDetail.shipMsg').replace('{location}', shippingLocation),
      )
    } 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 salesOrders.cancel(order.id)
      setOrder(updated)
      await reloadSideEffects(updated.code)
      setActionMessage(t('page.salesOrderDetail.cancelMsg'))
    } catch (e: unknown) {
      setError(e instanceof Error ? e : new Error(String(e)))
    } finally {
      setActing(false)
    }
  }

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

  return (
    <div>
      <PageHeader
        title={t('page.salesOrderDetail.title').replace('{code}', order.code)}
        subtitle={`${t('page.salesOrderDetail.subtitle').replace('{partner}', order.partnerCode)} \u00B7 ${order.orderDate} \u00B7 ${order.currencyCode}`}
        actions={
          <button className="btn-secondary" onClick={() => navigate('/sales-orders')}>
            {t('action.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">{t('label.status')}:</span>
            <StatusBadge status={order.status} />
          </div>
          <div className="text-right">
            <div className="text-xs uppercase text-slate-400">{t('label.total')}</div>
            <div className="font-mono text-xl font-semibold">
              {Number(order.totalAmount).toLocaleString(undefined, {
                minimumFractionDigits: 2,
                maximumFractionDigits: 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">{t('label.actions')}</h2>
        <div className="flex flex-wrap items-center gap-3">
          <button className="btn-primary" disabled={!canConfirm || acting} onClick={onConfirm}>
            {t('action.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={shippingLocation}
              onChange={(e) => setShippingLocation(e.target.value)}
              disabled={!canShip || acting}
            >
              {locations.map((l) => (
                <option key={l.id} value={l.code}>
                  {l.code} — {l.name}
                </option>
              ))}
            </select>
            <button className="btn-primary" disabled={!canShip || acting} onClick={onShip}>
              {t('action.ship')}
            </button>
          </div>
          <button className="btn-danger" disabled={!canCancel || acting} onClick={onCancel}>
            {t('action.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">{t('label.lines')}</h2>
        </div>
        <table className="table-base">
          <thead className="bg-slate-50">
            <tr>
              <th>{t('label.lineNo')}</th>
              <th>{t('label.item')}</th>
              <th>{t('label.qty')}</th>
              <th>{t('label.unitPrice')}</th>
              <th>{t('label.lineTotal')}</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">{t('label.inventoryMovements')}</h2>
          </div>
          {movements.length === 0 ? (
            <div className="p-5 text-sm text-slate-400">{t('label.noMovementsYet')}</div>
          ) : (
            <table className="table-base">
              <thead className="bg-slate-50">
                <tr>
                  <th>{t('label.item')}</th>
                  <th>{t('label.delta')}</th>
                  <th>{t('label.reason')}</th>
                  <th>{t('label.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-rose-600">{String(m.delta)}</td>
                    <td>{m.reason}</td>
                    <td className="font-mono text-xs text-slate-500">{m.reference ?? '\u2014'}</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">{t('label.journalEntries')}</h2>
          </div>
          {journalEntries.length === 0 ? (
            <div className="p-5 text-sm text-slate-400">{t('label.noEntriesYet')}</div>
          ) : (
            <table className="table-base">
              <thead className="bg-slate-50">
                <tr>
                  <th>{t('label.code')}</th>
                  <th>{t('label.type')}</th>
                  <th>{t('label.status')}</th>
                  <th>{t('label.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 className="border-t border-slate-200 px-5 py-3 text-xs text-slate-400">
            <Link to="/journal-entries" className="hover:underline">{t('label.viewAllJournalEntries')} &rarr;</Link>
          </div>
        </div>
      </div>
    </div>
  )
}