- Introduced a new Separator component for visual separation in UI. - Added Sheet component for modal-like functionality with triggers and overlays. - Implemented Toaster component for notifications using Sonner library. - Created Switch component for toggle functionality. - Developed Table component with headers, body, footer, and rows for structured data display. - Added Tabs component for organizing content into tabbed interfaces. - Implemented Textarea component for multi-line text input. - Created Tooltip component for displaying additional information on hover. feat: add PocketBase helpers and types - Implemented various helper functions for interacting with PocketBase collections (customers, companies, suppliers, quotes, orders). - Added type definitions for entities such as Customer, Company, Supplier, Quote, Order, and their respective items. - Included utility functions for generating unique identifiers for quotes, orders, and purchase orders. - Added functions for calculating totals and formatting currency and dates. chore: add TypeScript configuration - Added tsconfig.json for TypeScript support with strict type checking and module resolution.
255 lines
9.5 KiB
TypeScript
255 lines
9.5 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect } from "react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from "@/components/ui/sheet";
|
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
|
import { toast } from "sonner";
|
|
import { Loader2, ChevronsUpDown, Check } from "lucide-react";
|
|
import getPocketBase from "@/lib/pocketbase";
|
|
import {
|
|
getAllCustomers,
|
|
getAllCompanies,
|
|
getAllSuppliers,
|
|
generateOrderNumber,
|
|
} from "@/lib/pb-helpers";
|
|
import { cn } from "@/lib/utils";
|
|
import type { Customer, Company, Supplier, Order, OrderStatus } from "@/lib/types";
|
|
|
|
interface OrderFormProps {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
order?: Order | null;
|
|
onSaved: () => void;
|
|
}
|
|
|
|
export function OrderForm({ open, onOpenChange, order, onSaved }: OrderFormProps) {
|
|
const [loading, setLoading] = useState(false);
|
|
const [customers, setCustomers] = useState<Customer[]>([]);
|
|
const [companies, setCompanies] = useState<Company[]>([]);
|
|
const [suppliers, setSuppliers] = useState<Supplier[]>([]);
|
|
const [customerOpen, setCustomerOpen] = useState(false);
|
|
const [companyOpen, setCompanyOpen] = useState(false);
|
|
|
|
const [form, setForm] = useState({
|
|
order_number: "",
|
|
customer: "",
|
|
company: "",
|
|
quote: "",
|
|
status: "new" as OrderStatus,
|
|
notes: "",
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (open) {
|
|
loadDropdowns();
|
|
if (order) {
|
|
setForm({
|
|
order_number: order.order_number,
|
|
customer: order.customer || "",
|
|
company: order.company,
|
|
quote: order.quote || "",
|
|
status: order.status,
|
|
notes: order.notes || "",
|
|
});
|
|
} else {
|
|
initNewOrder();
|
|
}
|
|
}
|
|
}, [open, order]);
|
|
|
|
const loadDropdowns = async () => {
|
|
try {
|
|
const [custs, comps, supps] = await Promise.all([
|
|
getAllCustomers(),
|
|
getAllCompanies(),
|
|
getAllSuppliers(),
|
|
]);
|
|
setCustomers(custs);
|
|
setCompanies(comps);
|
|
setSuppliers(supps);
|
|
} catch {
|
|
toast.error("Failed to load dropdowns");
|
|
}
|
|
};
|
|
|
|
const initNewOrder = async () => {
|
|
try {
|
|
const orderNum = await generateOrderNumber();
|
|
setForm({
|
|
order_number: orderNum,
|
|
customer: "",
|
|
company: "",
|
|
quote: "",
|
|
status: "new",
|
|
notes: "",
|
|
});
|
|
} catch {
|
|
toast.error("Failed to generate order number");
|
|
}
|
|
};
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!form.company) {
|
|
toast.error("Please select a company");
|
|
return;
|
|
}
|
|
|
|
setLoading(true);
|
|
try {
|
|
const pb = getPocketBase();
|
|
if (order) {
|
|
await pb.collection("orders").update(order.id, form);
|
|
toast.success("Order updated");
|
|
} else {
|
|
await pb.collection("orders").create(form);
|
|
toast.success("Order created");
|
|
}
|
|
onSaved();
|
|
onOpenChange(false);
|
|
} catch (err: unknown) {
|
|
const message = err instanceof Error ? err.message : "Failed to save order";
|
|
toast.error(message);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const getCustomerName = (id: string) => customers.find((c) => c.id === id)?.customer || "";
|
|
const getCompanyName = (id: string) => companies.find((c) => c.id === id)?.company || "";
|
|
|
|
return (
|
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
|
<SheetContent className="w-full sm:max-w-lg overflow-y-auto">
|
|
<SheetHeader>
|
|
<SheetTitle>{order ? "Edit Order" : "New Order"}</SheetTitle>
|
|
<SheetDescription>
|
|
{order ? `Editing ${order.order_number}` : "Create a new order"}
|
|
</SheetDescription>
|
|
</SheetHeader>
|
|
<form onSubmit={handleSubmit} className="space-y-6 mt-6">
|
|
<div className="space-y-2">
|
|
<Label>Order Number</Label>
|
|
<Input value={form.order_number} readOnly className="bg-muted" />
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<Label>Customer</Label>
|
|
<Popover open={customerOpen} onOpenChange={setCustomerOpen}>
|
|
<PopoverTrigger
|
|
className="w-full justify-between inline-flex items-center rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1"
|
|
>
|
|
{form.customer ? getCustomerName(form.customer) : "Select customer..."}
|
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-full p-0">
|
|
<Command>
|
|
<CommandInput placeholder="Search customers..." />
|
|
<CommandList>
|
|
<CommandEmpty>No customer found.</CommandEmpty>
|
|
<CommandGroup>
|
|
{customers.map((c) => (
|
|
<CommandItem
|
|
key={c.id}
|
|
value={c.customer}
|
|
onSelect={() => {
|
|
setForm({ ...form, customer: c.id });
|
|
setCustomerOpen(false);
|
|
}}
|
|
>
|
|
<Check className={cn("mr-2 h-4 w-4", form.customer === c.id ? "opacity-100" : "opacity-0")} />
|
|
{c.customer}
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
</CommandList>
|
|
</Command>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label>Company *</Label>
|
|
<Popover open={companyOpen} onOpenChange={setCompanyOpen}>
|
|
<PopoverTrigger
|
|
className="w-full justify-between inline-flex items-center rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1"
|
|
>
|
|
{form.company ? getCompanyName(form.company) : "Select company..."}
|
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-full p-0">
|
|
<Command>
|
|
<CommandInput placeholder="Search companies..." />
|
|
<CommandList>
|
|
<CommandEmpty>No company found.</CommandEmpty>
|
|
<CommandGroup>
|
|
{companies.map((c) => (
|
|
<CommandItem
|
|
key={c.id}
|
|
value={c.company}
|
|
onSelect={() => {
|
|
setForm({ ...form, company: c.id });
|
|
setCompanyOpen(false);
|
|
}}
|
|
>
|
|
<Check className={cn("mr-2 h-4 w-4", form.company === c.id ? "opacity-100" : "opacity-0")} />
|
|
{c.company}
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
</CommandList>
|
|
</Command>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</div>
|
|
</div>
|
|
|
|
{order && (
|
|
<div className="space-y-2">
|
|
<Label>Status</Label>
|
|
<Select value={form.status} onValueChange={(v) => setForm({ ...form, status: (v || "new") as OrderStatus })}>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="new">New</SelectItem>
|
|
<SelectItem value="processing">Processing</SelectItem>
|
|
<SelectItem value="order_ready">Order Ready</SelectItem>
|
|
<SelectItem value="po_generated">PO Generated</SelectItem>
|
|
<SelectItem value="completed">Completed</SelectItem>
|
|
<SelectItem value="cancelled">Cancelled</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
)}
|
|
|
|
<div className="space-y-2">
|
|
<Label>Notes</Label>
|
|
<Textarea
|
|
value={form.notes}
|
|
onChange={(e) => setForm({ ...form, notes: e.target.value })}
|
|
rows={3}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex gap-3 pt-4">
|
|
<Button type="submit" disabled={loading} className="flex-1">
|
|
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
{order ? "Update" : "Create"} Order
|
|
</Button>
|
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
|
Cancel
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</SheetContent>
|
|
</Sheet>
|
|
);
|
|
}
|