finish bills , po , jobcards relational fields creation
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { FileText } from "lucide-react"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/components/ui/dialog"
|
||||
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||
import { BillForm } from "@/modules/bills/bill-form"
|
||||
import { toRelation } from "@/shared/lib/utils"
|
||||
import { usePurchaseOrder } from "./purchase-order-context"
|
||||
|
||||
/**
|
||||
* Maps a Purchase Order data object to a complete BillFormValues shape so that
|
||||
* useResourceForm's shallow spread (`{ ...defaultValues, ...initialData }`) correctly
|
||||
* pre-fills all relational and line-item fields.
|
||||
*/
|
||||
function mapPOToBillInitialData(po: Record<string, any>) {
|
||||
return {
|
||||
// Text fields
|
||||
title: po.title ?? "",
|
||||
notes: po.notes ?? "",
|
||||
|
||||
// Relation fields — must be { value, label } objects for RhfAsyncSelectField
|
||||
vendor: toRelation(po.vendor_id, po.vendor_name),
|
||||
department: toRelation(po.department_id, po.department_name),
|
||||
job_card: toRelation(po.job_card_id, po.job_card_name ?? po.job_card_number),
|
||||
// Link bill back to the source PO
|
||||
purchase_order: toRelation(po.id, po.order_number ?? po.title),
|
||||
|
||||
// Parts pre-filled from PO items
|
||||
part_items: (po.parts ?? []).map((p: any) => ({
|
||||
part_id: p.part_id ?? p.id,
|
||||
title: p.part?.title ?? p.title ?? "",
|
||||
quantity: Number(p.quantity) || 1,
|
||||
rate: Number(p.rate) || 0,
|
||||
description: p.description ?? "",
|
||||
})),
|
||||
|
||||
// Services and expenses start empty (PO doesn't have them)
|
||||
service_items: [],
|
||||
expense_items: [],
|
||||
}
|
||||
}
|
||||
|
||||
export function CreateBillFromPOButton() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const poContext = usePurchaseOrder()
|
||||
|
||||
if (!poContext) return null
|
||||
|
||||
const initialData = poContext.data ? mapPOToBillInitialData(poContext.data) : undefined
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
|
||||
<FileText className="me-2 size-4" />
|
||||
Create Bill
|
||||
</Button>
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="min-w-6xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Bill from Purchase Order</DialogTitle>
|
||||
</DialogHeader>
|
||||
<ScrollArea className="max-h-[75vh] px-1">
|
||||
<BillForm
|
||||
initialData={initialData}
|
||||
onSuccess={() => setOpen(false)}
|
||||
/>
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
"use client"
|
||||
|
||||
import { useAuthApi } from "@/shared/useApi"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/shared/components/ui/dropdown-menu"
|
||||
import { confirm } from "@/shared/components/confirm-dialog"
|
||||
import { Ellipsis, Pencil, Trash2 } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
type PurchaseOrderActionsProps = {
|
||||
purchaseOrderId: string
|
||||
}
|
||||
|
||||
export function PurchaseOrderActions({ purchaseOrderId }: PurchaseOrderActionsProps) {
|
||||
const api = useAuthApi()
|
||||
const router = useRouter()
|
||||
|
||||
const handleDelete = async () => {
|
||||
const confirmed = await confirm({
|
||||
title: "Delete this purchase order?",
|
||||
description: "This action cannot be undone.",
|
||||
confirmLabel: "Delete",
|
||||
variant: "destructive",
|
||||
})
|
||||
if (!confirmed) return
|
||||
|
||||
const promise = api.purchaseOrders.destroy(purchaseOrderId)
|
||||
toast.promise(promise, {
|
||||
loading: "Deleting purchase order...",
|
||||
success: "Purchase order deleted",
|
||||
error: "Failed to delete purchase order",
|
||||
})
|
||||
await promise
|
||||
router.push("/purchase/purchase-order")
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<Ellipsis className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem variant="destructive" onClick={handleDelete}>
|
||||
<Trash2 className="size-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
"use client"
|
||||
|
||||
import { createContext, useContext } from "react"
|
||||
|
||||
export type PurchaseOrderContextValue = {
|
||||
id: string
|
||||
label: string
|
||||
data?: Record<string, any>
|
||||
}
|
||||
|
||||
const PurchaseOrderContext = createContext<PurchaseOrderContextValue | null>(null)
|
||||
|
||||
export function PurchaseOrderProvider({
|
||||
purchaseOrder,
|
||||
children,
|
||||
}: {
|
||||
purchaseOrder: PurchaseOrderContextValue
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<PurchaseOrderContext.Provider value={purchaseOrder}>
|
||||
{children}
|
||||
</PurchaseOrderContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function usePurchaseOrder() {
|
||||
return useContext(PurchaseOrderContext)
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
RhfAsyncSelectField,
|
||||
RhfDateField,
|
||||
} from "@/shared/components/form"
|
||||
import { PartsSelectorField } from "@/modules/parts/parts-selector-field"
|
||||
import { toast } from "sonner"
|
||||
import { useAuthApi } from "@/shared/useApi"
|
||||
import { useResourceForm } from "@/shared/hooks/use-resource-form"
|
||||
@@ -37,12 +38,14 @@ export type PurchaseOrderFormProps = {
|
||||
|
||||
const DEFAULT_VALUES: PurchaseOrderFormValues = {
|
||||
vendor: null,
|
||||
order_number: "" ,
|
||||
job_card: null,
|
||||
department: null,
|
||||
title: "",
|
||||
order_date: new Date().toISOString().split("T")[0],
|
||||
delivery_date: "",
|
||||
notes: "",
|
||||
items: [],
|
||||
}
|
||||
|
||||
// ── Mapping helpers ──
|
||||
@@ -57,7 +60,15 @@ function mapToFormValues(data: unknown): PurchaseOrderFormValues {
|
||||
title: d.title || "",
|
||||
order_date: d.order_date || "",
|
||||
delivery_date: d.delivery_date || "",
|
||||
order_number: d.order_number || "" as any,
|
||||
notes: d.notes || "",
|
||||
items: (d.parts ?? []).map((p: any) => ({
|
||||
part_id: p.part_id ?? p.id,
|
||||
title: p.part?.title ?? p.title ?? "",
|
||||
quantity: p.quantity ?? 1,
|
||||
rate: Number(p.rate) || 0,
|
||||
description: p.description ?? "",
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,8 +79,15 @@ function mapFormToPayload(values: PurchaseOrderFormValues) {
|
||||
department_id: toId(values.department),
|
||||
title: values.title,
|
||||
order_date: values.order_date || undefined,
|
||||
order_number: values.order_number,
|
||||
delivery_date: values.delivery_date || undefined,
|
||||
notes: values.notes || undefined,
|
||||
items: (values.items ?? []).map((item) => ({
|
||||
part_id: item.part_id,
|
||||
quantity: item.quantity,
|
||||
rate: item.rate,
|
||||
description: item.description || undefined,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,9 +145,10 @@ export function PurchaseOrderForm({ resourceId, initialData, onSuccess }: Purcha
|
||||
)}
|
||||
|
||||
<FieldGroup>
|
||||
<RhfTextField name="title" label="Title" placeholder="Enter purchase order title" required />
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfTextField name="title" label="Title" placeholder="Enter purchase order title" required />
|
||||
<RhfTextField name="order_number" label="Order Number" placeholder="Enter purchase order number" required />
|
||||
<RhfDateField name="order_date" label="Order Date" />
|
||||
<RhfDateField name="delivery_date" label="Delivery Date" />
|
||||
</div>
|
||||
@@ -170,6 +189,8 @@ export function PurchaseOrderForm({ resourceId, initialData, onSuccess }: Purcha
|
||||
|
||||
<RhfTextareaField name="notes" label="Notes" rows={3} />
|
||||
|
||||
<PartsSelectorField<PurchaseOrderFormValues, "items"> name="items" />
|
||||
|
||||
<Button type="submit" variant="default" disabled={isPending}>
|
||||
{isPending ? (
|
||||
"Saving..."
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
import {
|
||||
ClipboardList,
|
||||
Calendar,
|
||||
Hash,
|
||||
Building2,
|
||||
Truck,
|
||||
FileText,
|
||||
Package,
|
||||
DollarSign,
|
||||
} from "lucide-react"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/shared/components/ui/card"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Separator } from "@/shared/components/ui/separator"
|
||||
import {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
} from "@/shared/components/ui/table"
|
||||
|
||||
type PurchaseOrderPart = {
|
||||
id?: number
|
||||
purchase_order_id?: number
|
||||
part_id?: number
|
||||
quantity?: number
|
||||
rate?: string
|
||||
description?: string
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
part?: {
|
||||
id?: number
|
||||
title?: string
|
||||
}
|
||||
}
|
||||
|
||||
type PurchaseOrderData = {
|
||||
id?: number
|
||||
job_card_id?: number
|
||||
vendor_id?: number
|
||||
vendor_name?: string
|
||||
job_card_name?: string
|
||||
department_name?: string
|
||||
title?: string
|
||||
order_number?: string
|
||||
order_date?: string
|
||||
delivery_date?: string
|
||||
department_id?: number
|
||||
notes?: string
|
||||
terms_and_conditions?: string
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
parts?: PurchaseOrderPart[]
|
||||
}
|
||||
|
||||
type PurchaseOrderGeneralInfoProps = {
|
||||
purchaseOrder: PurchaseOrderData
|
||||
}
|
||||
|
||||
function InfoItem({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
label: string
|
||||
value?: string | null
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
|
||||
<Icon className="size-4" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
<span className="text-sm font-medium">
|
||||
{value || <span className="text-muted-foreground">—</span>}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatDate(dateStr?: string | null) {
|
||||
if (!dateStr) return null
|
||||
return new Date(dateStr).toLocaleDateString()
|
||||
}
|
||||
|
||||
function formatCurrency(value?: string | number | null) {
|
||||
if (value == null) return "—"
|
||||
return `$${Number(value).toFixed(2)}`
|
||||
}
|
||||
|
||||
export function PurchaseOrderGeneralInfo({ purchaseOrder }: PurchaseOrderGeneralInfoProps) {
|
||||
const parts = purchaseOrder.parts ?? []
|
||||
const totalAmount = parts.reduce(
|
||||
(sum, p) => sum + (p.quantity ?? 0) * Number(p.rate ?? 0),
|
||||
0,
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="grid gap-6">
|
||||
{/* Top row: Order Info + Dates */}
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{/* Order Information */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<ClipboardList className="size-4" />
|
||||
Order Information
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{purchaseOrder.order_number && (
|
||||
<Badge variant="secondary">{purchaseOrder.order_number}</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<InfoItem
|
||||
icon={Hash}
|
||||
label="Order Number"
|
||||
value={purchaseOrder.order_number}
|
||||
/>
|
||||
<InfoItem
|
||||
icon={ClipboardList}
|
||||
label="Title"
|
||||
value={purchaseOrder.title}
|
||||
/>
|
||||
<InfoItem
|
||||
icon={Truck}
|
||||
label="Vendor"
|
||||
value={purchaseOrder.vendor_name ?? (purchaseOrder.vendor_id ? `Vendor #${purchaseOrder.vendor_id}` : null)}
|
||||
/>
|
||||
<InfoItem
|
||||
icon={Building2}
|
||||
label="Department"
|
||||
value={purchaseOrder.department_name ?? (purchaseOrder.department_id ? `Dept #${purchaseOrder.department_id}` : null)}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Dates & Notes */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Calendar className="size-4" />
|
||||
Dates & Notes
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<InfoItem
|
||||
icon={Calendar}
|
||||
label="Order Date"
|
||||
value={formatDate(purchaseOrder.order_date)}
|
||||
/>
|
||||
<InfoItem
|
||||
icon={Calendar}
|
||||
label="Delivery Date"
|
||||
value={formatDate(purchaseOrder.delivery_date)}
|
||||
/>
|
||||
<InfoItem
|
||||
icon={Calendar}
|
||||
label="Created"
|
||||
value={formatDate(purchaseOrder.created_at)}
|
||||
/>
|
||||
<InfoItem
|
||||
icon={Calendar}
|
||||
label="Updated"
|
||||
value={formatDate(purchaseOrder.updated_at)}
|
||||
/>
|
||||
</div>
|
||||
{purchaseOrder.notes && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
|
||||
<FileText className="size-4" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-xs text-muted-foreground">Notes</span>
|
||||
<p className="text-sm whitespace-pre-wrap">{purchaseOrder.notes}</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{purchaseOrder.terms_and_conditions && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
|
||||
<FileText className="size-4" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-xs text-muted-foreground">Terms & Conditions</span>
|
||||
<p className="text-sm whitespace-pre-wrap">{purchaseOrder.terms_and_conditions}</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Parts / Line Items */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<span className="flex items-center gap-2">
|
||||
<Package className="size-4" />
|
||||
Parts ({parts.length})
|
||||
</span>
|
||||
<span className="flex items-center gap-2 text-base">
|
||||
<DollarSign className="size-4" />
|
||||
Total: {formatCurrency(totalAmount)}
|
||||
</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{parts.length > 0 ? (
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-12">#</TableHead>
|
||||
<TableHead>Part</TableHead>
|
||||
<TableHead>Description</TableHead>
|
||||
<TableHead className="text-right">Qty</TableHead>
|
||||
<TableHead className="text-right">Rate</TableHead>
|
||||
<TableHead className="text-right">Amount</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{parts.map((item, index) => {
|
||||
const amount = (item.quantity ?? 0) * Number(item.rate ?? 0)
|
||||
return (
|
||||
<TableRow key={item.id ?? index}>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{index + 1}
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
{item.part?.title ?? `Part #${item.part_id}`}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{item.description || "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{item.quantity ?? 0}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatCurrency(item.rate)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-medium">
|
||||
{formatCurrency(amount)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
<TableRow className="bg-muted/50 font-semibold">
|
||||
<TableCell colSpan={5} className="text-right">
|
||||
Total
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatCurrency(totalAmount)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground text-center py-6">
|
||||
No parts added to this purchase order.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -4,6 +4,14 @@ const relationFieldSchema = z
|
||||
.object({ value: z.string(), label: z.string() })
|
||||
.nullable()
|
||||
|
||||
const purchaseOrderItemSchema = z.object({
|
||||
part_id: z.number(),
|
||||
title: z.string(),
|
||||
quantity: z.number().min(1, "Quantity must be at least 1"),
|
||||
rate: z.number().min(0, "Rate must be non-negative"),
|
||||
description: z.string().optional(),
|
||||
})
|
||||
|
||||
const purchaseOrderFormSchema = z.object({
|
||||
// ── Relations ──
|
||||
vendor: relationFieldSchema,
|
||||
@@ -15,9 +23,15 @@ const purchaseOrderFormSchema = z.object({
|
||||
order_date: z.string().optional(),
|
||||
delivery_date: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
order_number: z.string().min(1, "Order number is required"),
|
||||
|
||||
// ── Items (parts) ──
|
||||
items: z.array(purchaseOrderItemSchema).optional(),
|
||||
})
|
||||
|
||||
type PurchaseOrderFormValues = z.infer<typeof purchaseOrderFormSchema>
|
||||
type PurchaseOrderItem = z.infer<typeof purchaseOrderItemSchema>
|
||||
|
||||
export { purchaseOrderFormSchema, relationFieldSchema }
|
||||
export type { PurchaseOrderFormValues }
|
||||
export { purchaseOrderFormSchema, purchaseOrderItemSchema, relationFieldSchema }
|
||||
export type { PurchaseOrderFormValues, PurchaseOrderItem }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user