[dyad] Implemented "Edit Item" functionality for stock table - wrote 5 file(s)

This commit is contained in:
[dyad] 2025-09-10 11:23:49 +02:00
parent 837c8354ba
commit 8a5edf3e3b
4 changed files with 375 additions and 179 deletions

View file

@ -0,0 +1,195 @@
"use client"
import * as React from "react"
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import * as z from "zod"
import { Button } from "@/components/ui/button"
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import { showSuccess, showError } from "@/utils/toast"
import { CategoryCombobox } from "./CategoryCombobox"
import pb from "@/lib/pocketbase"
import { StockItem } from "./columns" // Assuming StockItem type is defined here
const formSchema = z.object({
id: z.string(), // Hidden field for item ID
name: z.string().min(2, { message: "Name must be at least 2 characters." }),
sku: z.string().min(2, { message: "SKU must be at least 2 characters." }),
category: z.string({ required_error: "Please select or create a category." }),
quantity: z.coerce.number().int().positive({ message: "Quantity must be a positive number." }),
costPrice: z.coerce.number().positive({ message: "Cost price must be a positive number." }),
sellingPrice: z.coerce.number().positive({ message: "Selling price must be a positive number." }),
})
interface EditItemFormProps {
setOpen: (open: boolean) => void;
refreshData: () => void;
item: StockItem; // The item data to be edited
}
export function EditItemForm({ setOpen, refreshData, item }: EditItemFormProps) {
const [categoryOptions, setCategoryOptions] = React.useState<{ value: string; label: string }[]>([]);
const fetchCategories = React.useCallback(async () => {
try {
const records = await pb.collection('stock_items').getList(1, 50, {
fields: 'category',
});
const uniqueCategories = Array.from(new Set(records.items.map((item: { category: string }) => item.category)));
const options = uniqueCategories.map((cat: string) => ({
value: cat.toLowerCase(),
label: cat,
}));
setCategoryOptions(options);
} catch (error) {
console.error("Error fetching categories for combobox:", error);
showError("Failed to load categories.");
}
}, []);
React.useEffect(() => {
fetchCategories();
}, [fetchCategories]);
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
id: item.id,
name: item.name,
sku: item.sku,
category: item.category.toLowerCase(), // Ensure category is lowercase for combobox value
quantity: item.quantity,
costPrice: item.costPrice,
sellingPrice: item.sellingPrice,
},
})
async function onSubmit(values: z.infer<typeof formSchema>) {
try {
await pb.collection('stock_items').update(values.id, {
name: values.name,
sku: values.sku,
category: values.category,
quantity: values.quantity,
cost_price: values.costPrice,
selling_price: values.sellingPrice,
});
showSuccess("Item updated successfully!");
setOpen(false);
refreshData(); // Trigger data refresh in StockList
fetchCategories(); // Re-fetch categories in case a new one was added/edited
} catch (error) {
console.error("Error updating item:", error);
showError("Failed to update item. Please try again.");
}
}
const handleCategoryCreate = (newCategory: string) => {
const formattedCategory = {
value: newCategory.toLowerCase(),
label: newCategory,
};
setCategoryOptions(prev => [...prev, formattedCategory]);
form.setValue("category", formattedCategory.value);
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Item Name</FormLabel>
<FormControl>
<Input placeholder="e.g., Wireless Mouse" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="sku"
render={({ field }) => (
<FormItem>
<FormLabel>SKU</FormLabel>
<FormControl>
<Input placeholder="e.g., LOG-M720" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="category"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>Category</FormLabel>
<CategoryCombobox
categories={categoryOptions}
value={field.value}
onChange={field.onChange}
onCategoryCreate={handleCategoryCreate}
/>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="quantity"
render={({ field }) => (
<FormItem>
<FormLabel>Quantity</FormLabel>
<FormControl>
<Input type="number" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="costPrice"
render={({ field }) => (
<FormItem>
<FormLabel>Cost Price</FormLabel>
<FormControl>
<Input type="number" step="0.01" placeholder="R0.00" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="sellingPrice"
render={({ field }) => (
<FormItem>
<FormLabel>Selling Price</FormLabel>
<FormControl>
<Input type="number" step="0.01" placeholder="R0.00" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<Button type="submit" className="w-full">Save Changes</Button>
</form>
</Form>
)
}

View file

@ -1,11 +1,8 @@
"use client"
import * as React from "react"
import { ColumnDef } from "@tanstack/react-table"
import { MoreHorizontal, ArrowUpDown } from "lucide-react"
import { MoreHorizontal } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import {
DropdownMenu,
DropdownMenuContent,
@ -14,8 +11,17 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { QRCodeDialog } from "./QRCodeDialog"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { useState } from "react"
import { EditItemForm } from "./EditItemForm" // Import the new EditItemForm
// Define the StockItem type based on your PocketBase collection structure
export type StockItem = {
id: string
name: string
@ -24,80 +30,14 @@ export type StockItem = {
quantity: number
costPrice: number
sellingPrice: number
status: "In Stock" | "Low Stock" | "Out of Stock"
}
const formatCurrency = (price: number) => {
return new Intl.NumberFormat("en-ZA", {
style: "currency",
currency: "ZAR",
}).format(price)
// Define a type for the props that the actions column will receive
interface ActionsColumnProps {
refreshData: () => void; // Function to refresh the parent data table
}
const ActionsCell = ({ row }: { row: any }) => {
const item = row.original
const [isQRCodeOpen, setQRCodeOpen] = React.useState(false)
return (
<>
<QRCodeDialog
item={item}
open={isQRCodeOpen}
onOpenChange={setQRCodeOpen}
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem onClick={() => setQRCodeOpen(true)}>
View QR Code
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => navigator.clipboard.writeText(item.id)}
>
Copy item ID
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>View details</DropdownMenuItem>
<DropdownMenuItem>Edit item</DropdownMenuItem>
<DropdownMenuItem className="text-red-600">Delete item</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
)
}
export const columns: ColumnDef<StockItem>[] = [
{
id: "select",
header: ({ table }) => (
<Checkbox
checked={
table.getIsAllPageRowsSelected()
? true
: table.getIsSomePageRowsSelected()
? "indeterminate"
: false
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
/>
),
enableSorting: false,
enableHiding: false,
},
export const createColumns = (props: ActionsColumnProps): ColumnDef<StockItem>[] => [
{
accessorKey: "name",
header: "Item Name",
@ -112,55 +52,72 @@ export const columns: ColumnDef<StockItem>[] = [
},
{
accessorKey: "quantity",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
className="text-center"
>
Quantity
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
)
},
cell: ({ row }) => {
const quantity = parseFloat(row.getValue("quantity"))
return <div className="text-center">{quantity}</div>
}
header: "Quantity",
},
{
accessorKey: "costPrice",
header: () => <div className="text-right">Cost Price</div>,
cell: ({ row }) => <div className="text-right font-medium">{formatCurrency(row.getValue("costPrice"))}</div>,
header: "Cost Price",
cell: ({ row }) => {
const amount = parseFloat(row.getValue("costPrice"))
const formatted = new Intl.NumberFormat("en-ZA", {
style: "currency",
currency: "ZAR",
}).format(amount)
return <div className="text-right font-medium">{formatted}</div>
},
},
{
accessorKey: "sellingPrice",
header: () => <div className="text-right">Selling Price</div>,
cell: ({ row }) => <div className="text-right font-medium">{formatCurrency(row.getValue("sellingPrice"))}</div>,
},
{
accessorKey: "status",
header: "Status",
header: "Selling Price",
cell: ({ row }) => {
const status = row.getValue("status") as "In Stock" | "Low Stock" | "Out of Stock"
const statusColor = {
"In Stock": "bg-green-500",
"Low Stock": "bg-yellow-500",
"Out of Stock": "bg-red-500",
};
return (
<div className="flex items-center">
<span className={`h-2.5 w-2.5 rounded-full mr-2 ${statusColor[status]}`} />
<span className={status !== 'In Stock' ? 'text-xs' : ''}>{status}</span>
</div>
)
}
const amount = parseFloat(row.getValue("sellingPrice"))
const formatted = new Intl.NumberFormat("en-ZA", {
style: "currency",
currency: "ZAR",
}).format(amount)
return <div className="text-right font-medium">{formatted}</div>
},
},
{
id: "actions",
cell: ActionsCell,
enableHiding: false,
cell: ({ row }) => {
const item = row.original
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
return (
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem
onClick={() => navigator.clipboard.writeText(item.id)}
>
Copy item ID
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setIsEditDialogOpen(true)}>Edit</DropdownMenuItem>
<DropdownMenuItem>Delete</DropdownMenuItem> {/* Implement delete functionality later */}
</DropdownMenuContent>
</DropdownMenu>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Edit Stock Item</DialogTitle>
<DialogDescription>
Make changes to the stock item here. Click save when you're done.
</DialogDescription>
</DialogHeader>
<EditItemForm setOpen={setIsEditDialogOpen} refreshData={props.refreshData} item={item} />
</DialogContent>
</Dialog>
)
},
},
]

89
src/pages/Stock.tsx Normal file
View file

@ -0,0 +1,89 @@
import { useState, useEffect, useCallback } from "react"
import { createColumns, StockItem } from "../components/stock/columns" // Changed from 'columns' to 'createColumns'
import { DataTable } from "../components/stock/data-table"
import { Button } from "@/components/ui/button"
import { PlusCircle } from "lucide-react"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { AddItemForm } from "../components/stock/AddItemForm"
import pb from "@/lib/pocketbase"
const Stock = () => {
const [addItemOpen, setAddItemOpen] = useState(false);
const [stockItems, setStockItems] = useState<StockItem[]>([]);
const [refreshTrigger, setRefreshTrigger] = useState(0); // State to trigger data refresh
const [filterValue, setFilterValue] = useState(""); // State for filtering
const fetchStockItems = useCallback(async () => {
try {
const records = await pb.collection('stock_items').getList(1, 50, {
sort: '-created',
});
const formattedData: StockItem[] = records.items.map((item: any) => ({
id: item.id,
name: item.name,
sku: item.sku,
category: item.category,
quantity: item.quantity,
costPrice: item.cost_price,
sellingPrice: item.selling_price,
}));
setStockItems(formattedData);
} catch (error) {
console.error("Error fetching stock items:", error);
}
}, []);
useEffect(() => {
fetchStockItems();
}, [fetchStockItems, refreshTrigger]); // Re-fetch data when refreshTrigger changes
const handleRefreshData = () => {
setRefreshTrigger(prev => prev + 1); // Increment to trigger useEffect
};
// Create columns with the refreshData function
const columns = createColumns({ refreshData: handleRefreshData });
return (
<div>
<div className="flex flex-col md:flex-row md:justify-between md:items-center gap-4 mb-6">
<div>
<h1 className="text-3xl font-bold">Stock</h1>
<p className="text-gray-500">Manage your inventory here.</p>
</div>
<Dialog open={addItemOpen} onOpenChange={setAddItemOpen}>
<DialogTrigger asChild>
<Button>
<PlusCircle className="mr-2 h-4 w-4" />
Add New Item
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Add New Stock Item</DialogTitle>
<DialogDescription>
Fill in the details below to add a new item to your stock.
</DialogDescription>
</DialogHeader>
<AddItemForm setOpen={setAddItemOpen} refreshData={handleRefreshData} />
</DialogContent>
</Dialog>
</div>
<DataTable
columns={columns}
data={stockItems}
filterValue={filterValue} // Pass filterValue
onFilterChange={setFilterValue} // Pass onFilterChange
/>
</div>
)
}
export default Stock;

View file

@ -1,33 +1,18 @@
import { useState, useEffect, useCallback } from "react";
import { columns, StockItem } from "../components/stock/columns";
import { createColumns, StockItem } from "../components/stock/columns"; // Changed from 'columns' to 'createColumns'
import { DataTable } from "../components/stock/data-table";
import { Button } from "@/components/ui/button";
import { PlusCircle, Camera } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { AddItemForm } from "../components/stock/AddItemForm";
import QRScannerDialog from "../components/stock/QRScannerDialog";
import pb from "@/lib/pocketbase";
import pb from "@/lib/pocketbase"; // Import PocketBase client
const StockList = () => {
const [addItemOpen, setAddItemOpen] = useState(false);
const [scannerOpen, setScannerOpen] = useState(false);
const [filterValue, setFilterValue] = useState("");
const [data, setData] = useState<StockItem[]>([]);
const [refreshTrigger, setRefreshTrigger] = useState(0); // New state to trigger data refresh
const [stockItems, setStockItems] = useState<StockItem[]>([]);
const [refreshTrigger, setRefreshTrigger] = useState(0); // State to trigger data refresh
const [filterValue, setFilterValue] = useState(""); // State for filtering
const fetchData = useCallback(async () => {
const fetchStockItems = useCallback(async () => {
try {
const records = await pb.collection('stock_items').getList(1, 50, {
sort: '-created',
});
// Map PocketBase records to StockItem type, adjusting field names
const formattedData: StockItem[] = records.items.map((item: any) => ({
id: item.id,
name: item.name,
@ -36,68 +21,38 @@ const StockList = () => {
quantity: item.quantity,
costPrice: item.cost_price,
sellingPrice: item.selling_price,
status: item.quantity > 10 ? "In Stock" : (item.quantity > 0 ? "Low Stock" : "Out of Stock"), // Example status logic
}));
setData(formattedData);
setStockItems(formattedData);
} catch (error) {
console.error("Error fetching stock items:", error);
}
}, []);
useEffect(() => {
fetchData();
}, [fetchData, refreshTrigger]); // Add refreshTrigger to dependencies
const handleScanSuccess = (decodedText: string) => {
setFilterValue(decodedText);
setScannerOpen(false);
};
fetchStockItems();
}, [fetchStockItems, refreshTrigger]); // Re-fetch data when refreshTrigger changes
const handleRefreshData = () => {
setRefreshTrigger(prev => prev + 1); // Increment to trigger useEffect
};
// Create columns with the refreshData function
const columns = createColumns({ refreshData: handleRefreshData });
return (
<div>
<QRScannerDialog
open={scannerOpen}
onOpenChange={setScannerOpen}
onScanSuccess={handleScanSuccess}
/>
<div className="flex flex-col md:flex-row md:justify-between md:items-center gap-4 mb-6">
<div>
<h1 className="text-3xl font-bold">Stock List</h1>
<p className="text-gray-500">Manage your inventory here.</p>
</div>
<div className="flex items-center space-x-2">
<Button variant="outline" onClick={() => setScannerOpen(true)}>
<Camera className="mr-2 h-4 w-4" />
Scan Barcode
</Button>
<Dialog open={addItemOpen} onOpenChange={setAddItemOpen}>
<DialogTrigger asChild>
<Button>
<PlusCircle className="mr-2 h-4 w-4" />
Add New Item
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Add New Stock Item</DialogTitle>
<DialogDescription>
Fill in the details below to add a new item to your inventory.
</DialogDescription>
</DialogHeader>
<AddItemForm setOpen={setAddItemOpen} refreshData={handleRefreshData} />
</DialogContent>
</Dialog>
<p className="text-gray-500">View all your inventory items.</p>
</div>
{/* No Add New Item button here, as this is a list-only page */}
</div>
<DataTable
columns={columns}
data={data}
filterValue={filterValue}
onFilterChange={setFilterValue}
<DataTable
columns={columns}
data={stockItems}
filterValue={filterValue} // Pass filterValue
onFilterChange={setFilterValue} // Pass onFilterChange
/>
</div>
)