feat(phase7.4): order rejection + resubmit flow

- Migration 053: rejection_reason TEXT NULL on orders table
- POST /api/orders/{id}/reject (PM+): sets rejected status, cancels
  active renders, stores reason, notifies creator, broadcasts WS event
- POST /api/orders/{id}/resubmit (creator or PM+): resets to draft,
  clears rejection fields, notifies PMs
- OrderDetail: Reject button (PM+) + inline modal with reason textarea
  and notify-client checkbox; Resubmit button; rejection reason amber
  alert box shown below header when order is rejected
- Orders Kanban: rejection_reason shown as red italic note on card
- Order interface: rejected_at, rejection_reason fields

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-08 20:37:05 +01:00
parent 596360e507
commit 1cc10d4bbb
7 changed files with 334 additions and 4 deletions
+122 -1
View File
@@ -8,9 +8,10 @@ import {
ChevronDown, ChevronUp, ChevronsUpDown,
Search, SlidersHorizontal, FileSpreadsheet, Box,
Loader2, Play, RefreshCw, ExternalLink, Ban, StopCircle, Scissors, Plus, Wand2, Download,
XCircle, RotateCw,
} from 'lucide-react'
import { toast } from 'sonner'
import { getOrder, submitOrder, deleteOrder, unlinkCadFile, regenerateItemThumbnail, patchOrderItem, removeOrderLine, dispatchRenders, cancelLineRender, cancelOrderRenders, splitMissingStep, generateLinesFromItems, downloadOrderRenders } from '../api/orders'
import { getOrder, submitOrder, deleteOrder, unlinkCadFile, regenerateItemThumbnail, patchOrderItem, removeOrderLine, dispatchRenders, cancelLineRender, cancelOrderRenders, splitMissingStep, generateLinesFromItems, downloadOrderRenders, rejectOrder, resubmitOrder } from '../api/orders'
import type { OrderItem, OrderLine } from '../api/orders'
import { listOutputTypes } from '../api/outputTypes'
import type { OutputType } from '../api/outputTypes'
@@ -60,6 +61,9 @@ export default function OrderDetailPage() {
const [genLinesOpen, setGenLinesOpen] = useState(false)
const [genLinesSelected, setGenLinesSelected] = useState<Record<string, boolean>>({})
const [isDownloading, setIsDownloading] = useState(false)
const [rejectModalOpen, setRejectModalOpen] = useState(false)
const [rejectReason, setRejectReason] = useState('')
const [rejectNotifyClient, setRejectNotifyClient] = useState(true)
// Table state
const [filters, setFilters] = useState<TableFilters>(EMPTY_FILTERS)
@@ -148,6 +152,28 @@ export default function OrderDetailPage() {
onError: (e: any) => toast.error(e.response?.data?.detail || 'Split failed'),
})
const rejectMut = useMutation({
mutationFn: () => rejectOrder(id!, rejectReason, rejectNotifyClient),
onSuccess: () => {
toast.success('Order rejected')
setRejectModalOpen(false)
setRejectReason('')
qc.invalidateQueries({ queryKey: ['order', id] })
qc.invalidateQueries({ queryKey: ['orders'] })
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Reject failed'),
})
const resubmitMut = useMutation({
mutationFn: () => resubmitOrder(id!),
onSuccess: () => {
toast.success('Order moved back to draft')
qc.invalidateQueries({ queryKey: ['order', id] })
qc.invalidateQueries({ queryKey: ['orders'] })
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Resubmit failed'),
})
if (isLoading) return <div className="p-8 text-center text-content-muted">Loading</div>
if (!order) return <div className="p-8 text-center text-red-500">Order not found</div>
@@ -155,6 +181,8 @@ export default function OrderDetailPage() {
const canDelete = order.status === 'draft' || order.status === 'rejected'
const isDraft = order.status === 'draft'
const isPrivileged = user?.role === 'admin' || user?.role === 'project_manager'
const canReject = isPrivileged && (order.status === 'submitted' || order.status === 'processing')
const canResubmit = order.status === 'rejected' && (isPrivileged || order.created_by === user?.id)
const rp = order.render_progress
const hasRetryable = rp && (rp.pending > 0 || rp.failed > 0 || (rp as any).cancelled > 0)
const canDispatch = isPrivileged && (order.status === 'processing' || order.status === 'submitted' || order.status === 'completed')
@@ -272,6 +300,25 @@ export default function OrderDetailPage() {
: 'Dispatch Renders'}
</button>
)}
{canReject && (
<button
onClick={() => setRejectModalOpen(true)}
className="px-3 py-2 rounded-lg text-sm font-medium border border-red-300 text-red-600 bg-red-50 hover:bg-red-100 transition-colors disabled:opacity-50 flex items-center gap-1.5"
>
<XCircle size={16} />
Reject Order
</button>
)}
{canResubmit && (
<button
onClick={() => resubmitMut.mutate()}
className="px-3 py-2 rounded-lg text-sm font-medium border border-border-default text-content-secondary bg-surface hover:bg-surface-hover transition-colors disabled:opacity-50 flex items-center gap-1.5"
disabled={resubmitMut.isPending}
>
<RotateCw size={16} />
{resubmitMut.isPending ? 'Moving to draft…' : 'Resubmit (back to draft)'}
</button>
)}
{canSubmit && (
<button
onClick={() => submitMut.mutate()}
@@ -381,6 +428,22 @@ export default function OrderDetailPage() {
</div>
)}
{order.status === 'rejected' && order.rejection_reason && (
<div className="card p-4 mb-6 bg-amber-50 border-amber-300 border flex items-start gap-3">
<XCircle size={18} className="text-red-500 shrink-0 mt-0.5" />
<div>
<p className="text-sm font-semibold text-red-700">Order Rejected</p>
<p className="text-sm text-amber-800 mt-0.5">{order.rejection_reason}</p>
</div>
</div>
)}
{order.status === 'rejected' && !order.rejection_reason && (
<div className="card p-4 mb-6 bg-red-50 border-red-200 border flex items-center gap-3">
<XCircle size={18} className="text-red-500 shrink-0" />
<p className="text-sm font-semibold text-red-700">This order was rejected.</p>
</div>
)}
{order.notes && (
<div className="card p-4 mb-6 bg-status-info-bg border-border-default">
<p className="text-sm text-status-info-text">{order.notes}</p>
@@ -688,6 +751,64 @@ export default function OrderDetailPage() {
)}
</div>
{/* Reject Order Modal */}
{rejectModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-surface rounded-xl shadow-2xl border border-border-default w-full max-w-md mx-4 p-6">
<div className="flex items-center gap-3 mb-4">
<XCircle size={20} className="text-red-500 shrink-0" />
<h2 className="text-lg font-semibold text-content">Reject Order</h2>
<button
onClick={() => { setRejectModalOpen(false); setRejectReason('') }}
className="ml-auto p-1 text-content-muted hover:text-content transition-colors rounded"
>
<X size={16} />
</button>
</div>
<p className="text-sm text-content-secondary mb-4">
Rejecting <span className="font-semibold font-mono">{order.order_number}</span> will
cancel all pending renders and move the order to &ldquo;rejected&rdquo; status.
</p>
<div className="mb-4">
<label className="block text-xs font-medium text-content-secondary mb-1.5">
Rejection reason (optional)
</label>
<textarea
value={rejectReason}
onChange={(e) => setRejectReason(e.target.value)}
placeholder="Describe why this order is being rejected…"
rows={3}
className="w-full px-3 py-2 text-sm border border-border-default rounded-lg bg-surface-alt focus:outline-none focus:ring-2 focus:ring-accent resize-none"
/>
</div>
<label className="flex items-center gap-2 text-sm text-content-secondary mb-5 cursor-pointer select-none">
<input
type="checkbox"
checked={rejectNotifyClient}
onChange={(e) => setRejectNotifyClient(e.target.checked)}
className="w-4 h-4 rounded accent-accent"
/>
Notify the order creator
</label>
<div className="flex items-center gap-3 justify-end">
<button
onClick={() => { setRejectModalOpen(false); setRejectReason('') }}
className="btn-secondary"
>
Cancel
</button>
<button
onClick={() => rejectMut.mutate()}
disabled={rejectMut.isPending}
className="px-4 py-2 rounded-lg text-sm font-semibold bg-red-600 hover:bg-red-700 text-white transition-colors disabled:opacity-50 flex items-center gap-2"
>
<XCircle size={15} />
{rejectMut.isPending ? 'Rejecting…' : 'Confirm Rejection'}
</button>
</div>
</div>
</div>
)}
</div>
)
}
+6 -1
View File
@@ -711,7 +711,12 @@ function KanbanCard({
{rp && rp.total > 0 && (
<RenderProgressBar progress={rp} />
)}
{order.notes && !rp && (
{order.status === 'rejected' && order.rejection_reason && (
<p className="mt-2 text-xs text-red-500 italic truncate" title={order.rejection_reason}>
{order.rejection_reason}
</p>
)}
{order.notes && !rp && order.status !== 'rejected' && (
<p className="mt-2 text-xs text-content-muted truncate">{order.notes}</p>
)}
<div className="mt-2 flex items-center justify-end opacity-0 group-hover:opacity-100 transition-opacity">