first version

This commit is contained in:
2025-11-12 15:30:12 +01:00
commit f668b6f006
161 changed files with 31955 additions and 0 deletions
@@ -0,0 +1,36 @@
"use server"
import { AssetWithAssignment } from "@/lib/types"
import { AssetService } from "@/services/asset.service"
import { ItemService } from "@/services/item.service"
import { RecipientService } from "@/services/recipient.service"
import EditAssetForm from "../../_components/edit.asset.form"
export default async function EditAssetPage({
params,
}: {
params: Promise<{ assetId: string }>
}) {
const { assetId } = await params
const items = await ItemService.findAll()
const recipients = await RecipientService.findAll()
const asset = await AssetService.findById(assetId)
if (!asset) {
return <div>Asset not found</div>
}
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-4">
<h1 className="text-2xl font-bold">Edit Asset</h1>
</div>
<EditAssetForm
items={items}
recipients={recipients}
asset={asset as unknown as AssetWithAssignment}
/>
</div>
)
}
@@ -0,0 +1,181 @@
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { useRouter } from "next/navigation"
import { useForm } from "react-hook-form"
import { toast } from "sonner"
import { SubmitButton } from "@/components/forms/submitButton"
import { ItemStatus } from "@/generated/prisma/client"
import { updateAssetAction } from "@/lib/actions/asset.actions"
import {
UpdateAssetFormType,
updateAssetSchema,
} from "@/lib/schemas/asset.schemas"
import {
AssetWithAssignment,
Item,
Recipient,
UpdateAssetStatus,
} from "@/lib/types"
interface EditAssetFormProps {
asset: AssetWithAssignment
items: Item[]
recipients: Recipient[]
}
export default function EditAssetForm({
asset,
items,
recipients,
}: EditAssetFormProps) {
const router = useRouter()
const {
register,
handleSubmit,
setError,
formState: { errors, isSubmitting, isSubmitSuccessful },
watch,
} = useForm<UpdateAssetFormType>({
resolver: zodResolver(updateAssetSchema),
defaultValues: {
id: asset.id,
itemId: asset.itemId ?? "",
serialNumber: asset.serialNumber,
deliveryNote: asset.deliveryNote ?? "",
status: asset.status as UpdateAssetStatus,
recipientId: asset.assignment?.recipientId ?? "",
},
shouldFocusError: true,
mode: "onSubmit",
})
const status = watch("status")
const onSubmit = async (formData: UpdateAssetFormType) => {
const response = await updateAssetAction(formData)
if (response?.errors) {
Object.entries(response.errors).forEach(([fieldName, messages]) => {
messages.forEach((msg: string) => {
setError(fieldName as keyof UpdateAssetFormType, {
type: "server",
message: msg,
})
toast.error(msg)
})
})
return
}
if (response?.success) {
toast.success("Asset updated successfully")
router.push(`/inventory/assets`)
}
}
return (
<form className="flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)}>
<input type="hidden" {...register("id")} />
<div>
<label htmlFor="categoryId" className="mb-2 block text-lg">
Item
</label>
<select
id="itemId"
{...register("itemId")}
className="w-full rounded-lg border px-4 py-2"
>
<option value="">Select a item:</option>
{items?.map((item) => (
<option key={item.id} value={item.id}>
{item.name}
</option>
))}
</select>
{errors?.itemId && (
<p className="text-error">{errors.itemId.message}</p>
)}
</div>
<div>
<label htmlFor="serialNumber" className="mb-2 block text-lg">
Serial Number
</label>
<input
type="text"
id="serialNumber"
placeholder="Serial number"
{...register("serialNumber")}
className="w-full rounded-lg border px-4 py-2"
/>
{errors?.serialNumber && (
<p className="text-error">{errors?.serialNumber?.message}</p>
)}
</div>
<div>
<label htmlFor="deliveryNote" className="mb-2 block text-lg">
Delivery Note
</label>
<input
type="text"
id="deliveryNote"
placeholder="Delivery note"
{...register("deliveryNote")}
className="w-full rounded-lg border px-4 py-2"
/>
{errors?.deliveryNote && (
<p className="text-error">{errors.deliveryNote.message}</p>
)}
</div>
<div>
<label htmlFor="status" className="mb-2 block text-lg">
Status
</label>
<select
id="status"
{...register("status")}
className="w-full rounded-lg border px-4 py-2"
>
<option value="">Select a status</option>
{Object.values(ItemStatus).map((status) => (
<option key={status} value={status}>
{status}
</option>
))}
</select>
{errors?.status && (
<p className="text-error">{errors.status.message}</p>
)}
</div>
{status === "ASSIGNED" && (
<div>
<label htmlFor="recipientId" className="mb-2 block text-lg">
Recipient
</label>
<select
id="recipientId"
{...register("recipientId")}
className="w-full rounded-lg border px-4 py-2"
>
<option value="">Select a Recipient</option>
{recipients?.map((recipient) => (
<option key={recipient.id} value={recipient.id}>
{recipient.firstName} {recipient.lastName}
</option>
))}
</select>
{errors?.recipientId && (
<p className="text-error">{errors.recipientId.message}</p>
)}
</div>
)}
<SubmitButton
isSubmitting={isSubmitting}
isSubmitSuccessful={isSubmitSuccessful}
>
Update Asset
</SubmitButton>
</form>
)
}
@@ -0,0 +1,166 @@
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { useRouter } from "next/navigation"
import { useForm } from "react-hook-form"
import { toast } from "sonner"
import { SubmitButton } from "@/components/forms/submitButton"
import { ItemStatus } from "@/generated/prisma/client"
import { createAssetAction } from "@/lib/actions/asset.actions"
import {
CreateAssetFormType,
createAssetSchema,
} from "@/lib/schemas/asset.schemas"
import { ItemWithoutStock, Recipient } from "@/lib/types"
interface NewAssetFormProps {
items: ItemWithoutStock[]
recipients: Recipient[]
}
export default function NewAssetForm({ items, recipients }: NewAssetFormProps) {
const router = useRouter()
const {
register,
handleSubmit,
setError,
formState: { errors, isSubmitting, isSubmitSuccessful },
watch,
} = useForm<CreateAssetFormType>({
resolver: zodResolver(createAssetSchema),
defaultValues: {
status: "AVAILABLE",
},
shouldFocusError: true,
mode: "onSubmit",
})
const status = watch("status")
const onSubmit = async (formData: CreateAssetFormType) => {
const response = await createAssetAction(formData)
if (response?.errors) {
Object.entries(response.errors).forEach(([fieldName, messages]) => {
messages.forEach((msg: string) => {
setError(fieldName as keyof CreateAssetFormType, {
type: "server",
message: msg,
})
toast.error(msg)
})
})
return
}
if (response?.success) {
toast.success("Asset created successfully")
router.push(`/inventory/assets`)
}
}
return (
<form className="flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)}>
<input type="hidden" {...register("id")} />
<div>
<label htmlFor="categoryId" className="mb-2 block text-lg">
Item
</label>
<select
id="itemId"
{...register("itemId")}
className="w-full rounded-lg border px-4 py-2"
>
<option value="">Select a item:</option>
{items?.map((item) => (
<option key={item.id} value={item.id}>
{item.name}
</option>
))}
</select>
{errors?.itemId && (
<p className="text-error">{errors.itemId.message}</p>
)}
</div>
<div>
<label htmlFor="serialNumber" className="mb-2 block text-lg">
Serial Number
</label>
<input
type="text"
id="serialNumber"
placeholder="Serial number"
{...register("serialNumber")}
className="w-full rounded-lg border px-4 py-2"
/>
{errors?.serialNumber && (
<p className="text-error">{errors?.serialNumber?.message}</p>
)}
</div>
<div>
<label htmlFor="deliveryNote" className="mb-2 block text-lg">
Delivery Note
</label>
<input
type="text"
id="deliveryNote"
placeholder="Delivery note"
{...register("deliveryNote")}
className="w-full rounded-lg border px-4 py-2"
/>
{errors?.deliveryNote && (
<p className="text-error">{errors.deliveryNote.message}</p>
)}
</div>
<div>
<label htmlFor="status" className="mb-2 block text-lg">
Status
</label>
<select
id="status"
{...register("status")}
className="w-full rounded-lg border px-4 py-2"
>
<option value="">Select a status</option>
{Object.values(ItemStatus).map((status) => (
<option key={status} value={status}>
{status}
</option>
))}
</select>
{errors?.status && (
<p className="text-error">{errors.status.message}</p>
)}
</div>
{status === "ASSIGNED" && (
<div>
<label htmlFor="recipientId" className="mb-2 block text-lg">
Recipient
</label>
<select
id="recipientId"
{...register("recipientId")}
className="w-full rounded-lg border px-4 py-2"
>
<option value="">Select a Recipient</option>
{recipients?.map((recipient) => (
<option key={recipient.id} value={recipient.id}>
{recipient.firstName} {recipient.lastName}
</option>
))}
</select>
{errors?.recipientId && (
<p className="text-error">{errors.recipientId.message}</p>
)}
</div>
)}
<SubmitButton
isSubmitting={isSubmitting}
isSubmitSuccessful={isSubmitSuccessful}
>
Create Asset
</SubmitButton>
</form>
)
}
@@ -0,0 +1,20 @@
"use server"
import { ItemService } from "@/services/item.service"
import { RecipientService } from "@/services/recipient.service"
import NewAssetForm from "../_components/new.asset.form"
export default async function NewAssetPage() {
const items = await ItemService.findAllAssignable()
const recipients = await RecipientService.findAll()
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-4">
<h1 className="text-2xl font-bold">New Asset</h1>
</div>
<NewAssetForm items={items} recipients={recipients} />
</div>
)
}
@@ -0,0 +1,91 @@
import { Pencil } from "lucide-react"
import Link from "next/link"
import PageHeader from "@/components/common/pageheader"
import PaginationButtons from "@/components/common/pagination"
import { Button } from "@/components/ui/button"
import { AssetService } from "@/services/asset.service"
export default async function AssetsPage(props: {
searchParams?: Promise<{
page?: string
search?: string
}>
}) {
const searchParams = await props.searchParams
const currentPage = searchParams?.page ? parseInt(searchParams.page) : 1
const search = searchParams?.search || ""
const { data: assets, totalPages } =
await AssetService.findAllWithItemAndCategory({
page: currentPage,
pageSize: 10,
search,
})
return (
<div className="flex flex-col gap-4">
<PageHeader
title="Assets"
link="/inventory/assets/new"
data={assets}
search={search}
/>
{assets.length === 0 && currentPage === 1 && (
<div className="flex gap-4">
<div className="flex items-center justify-between gap-4">
No Assets found.
</div>
</div>
)}
{assets.length > 0 && (
<div className="overflow-x-auto">
<table className="text-muted-foreground w-full text-left text-sm">
<thead className="border-b">
<tr>
<th scope="col" className="p-4">
Item Name
</th>
<th scope="col" className="p-4">
Category
</th>
<th scope="col" className="p-4">
Serial Number
</th>
<th scope="col" className="p-4">
Status
</th>
<th scope="col" className="p-4">
Actions
</th>
</tr>
</thead>
<tbody>
{assets.map((asset) => (
<tr key={asset.id} className="border-b">
<td className="p-4">{asset.item?.name}</td>
<td className="p-4">{asset.item?.category?.name}</td>
<td className="p-4">{asset.serialNumber}</td>
<td className="p-4">{asset.status}</td>
<td className="flex items-center gap-2 p-4">
<Link href={`/inventory/assets/${asset.id}/edit`} passHref>
<Button variant="outline" size="icon">
<Pencil />
</Button>
</Link>
</td>
</tr>
))}
</tbody>
<tfoot className="border-t">
<tr>
<td colSpan={5} className="p-4 text-center text-sm">
<PaginationButtons totalPages={totalPages} />
</td>
</tr>
</tfoot>
</table>
</div>
)}
</div>
)
}
@@ -0,0 +1,27 @@
import { notFound } from "next/navigation"
import { CategoryService } from "@/services/category.service"
import EditCategoryForm from "../../_components/edit.category.form"
export default async function EditCategoryPage({
params,
}: {
params: Promise<{ categoryId: string }>
}) {
const { categoryId } = await params
const category = await CategoryService.findById(categoryId)
if (!category) {
notFound()
}
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-4">
<h1 className="text-2xl font-bold">Edit Category</h1>
</div>
<EditCategoryForm category={category} />
</div>
)
}
@@ -0,0 +1,51 @@
"use client"
import { Trash } from "lucide-react"
import { useRouter } from "next/navigation"
import { useTransition } from "react"
import { toast } from "sonner"
import { Button } from "@/components/ui/button"
import { deleteCategoryAction } from "@/lib/actions/category.actions"
export default function DeleteCategoryButton({
categoryId,
}: {
categoryId: string
}) {
const router = useRouter()
const [isPending, startTransition] = useTransition()
const handleDelete = (formData: FormData) => {
startTransition(async () => {
const response = await deleteCategoryAction(formData)
if (!response.success && response.errors?.id) {
toast.error(response.errors.id[0])
return
}
if (response.success) {
toast.success(response.message)
router.refresh()
} else {
toast.error(response.message ?? "Unknown error")
}
})
}
return (
<form action={handleDelete}>
<input type="hidden" name="id" value={categoryId} />
<Button
type="submit"
className="btn btn-error"
size="icon"
variant="outline"
disabled={isPending}
>
<Trash />
</Button>
</form>
)
}
@@ -0,0 +1,84 @@
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { useRouter } from "next/navigation"
import { useForm } from "react-hook-form"
import { toast } from "sonner"
import { SubmitButton } from "@/components/forms/submitButton"
import { updateCategoryAction } from "@/lib/actions/category.actions"
import {
UpdateCategoryFormType,
updateCategorySchema,
} from "@/lib/schemas/category.schemas"
import { CategorySummary } from "@/lib/types"
export default function EditCategoryForm({
category,
}: {
category: CategorySummary
}) {
const router = useRouter()
const {
register,
handleSubmit,
setError,
formState: { errors, isSubmitting, isSubmitSuccessful },
} = useForm<UpdateCategoryFormType>({
resolver: zodResolver(updateCategorySchema),
defaultValues: {
id: category.id,
name: category.name,
},
})
const onSubmit = async (formData: UpdateCategoryFormType) => {
const response = await updateCategoryAction(formData)
if (response?.errors) {
Object.entries(response.errors).forEach(([fieldName, messages]) => {
messages.forEach((msg: string) => {
setError(fieldName as keyof UpdateCategoryFormType, {
type: "server",
message: msg,
})
toast.error(msg)
})
})
return
}
if (response?.success) {
toast.success(response.message)
router.push("/inventory/categories")
}
}
return (
<form className="flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)}>
<input type="hidden" {...register("id")} />
<div className="flex flex-col gap-2">
<label htmlFor="name" className="mb-2 block text-lg">
Name
</label>
<input
type="text"
id="name"
placeholder="Category name"
{...register("name")}
className={`w-full rounded-lg border px-4 py-2 ${
errors.name ? "border-error" : ""
}`}
/>
{errors.name && <p className="text-error">{errors.name.message}</p>}
</div>
<SubmitButton
isSubmitting={isSubmitting}
isSubmitSuccessful={isSubmitSuccessful}
>
Update Category
</SubmitButton>
</form>
)
}
@@ -0,0 +1,74 @@
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { useRouter } from "next/navigation"
import { useForm } from "react-hook-form"
import { toast } from "sonner"
import { SubmitButton } from "@/components/forms/submitButton"
import { createCategoryAction } from "@/lib/actions/category.actions"
import {
CreateCategoryFormType,
createCategorySchema,
} from "@/lib/schemas/category.schemas"
export default function NewCategoryForm() {
const router = useRouter()
const {
register,
handleSubmit,
setError,
formState: { errors, isSubmitting, isSubmitSuccessful },
} = useForm<CreateCategoryFormType>({
resolver: zodResolver(createCategorySchema),
})
const onSubmit = async (formData: CreateCategoryFormType) => {
const response = await createCategoryAction(formData)
if (response?.errors) {
Object.entries(response.errors).forEach(([fieldName, messages]) => {
messages.forEach((msg: string) => {
setError(fieldName as keyof CreateCategoryFormType, {
type: "server",
message: msg,
})
toast.error(msg)
})
})
return
}
if (response?.success) {
toast.success(response.message)
router.push("/inventory/categories")
}
}
return (
<form className="flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)}>
<div className="flex flex-col gap-2">
<label htmlFor="name" className="mb-2 block text-lg">
Name
</label>
<input
type="text"
id="name"
placeholder="Category name"
{...register("name")}
className={`w-full rounded-lg border px-4 py-2 ${
errors.name ? "border-error" : ""
}`}
/>
{errors.name && <p className="text-error">{errors.name.message}</p>}
</div>
<SubmitButton
isSubmitting={isSubmitting}
isSubmitSuccessful={isSubmitSuccessful}
>
Create Category
</SubmitButton>
</form>
)
}
@@ -0,0 +1,12 @@
import NewCategoryForm from "../_components/new.category.form"
export default function NewCategoryPage() {
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-4">
<h1 className="text-2xl font-bold">New Category</h1>
</div>
<NewCategoryForm />
</div>
)
}
@@ -0,0 +1,94 @@
import { Pencil } from "lucide-react"
import Link from "next/link"
import PageHeader from "@/components/common/pageheader"
import PaginationButtons from "@/components/common/pagination"
import { Button } from "@/components/ui/button"
import { CategoryService } from "@/services/category.service"
import DeleteCategoryButton from "./_components/delete.category.button"
export default async function Items(props: {
searchParams?: Promise<{
page?: string
search?: string
}>
}) {
const searchParams = await props.searchParams
const currentPage = searchParams?.page ? parseInt(searchParams.page) : 1
const search = searchParams?.search || ""
const { data: categories, totalPages } =
await CategoryService.findAllWithItemsCountPaginated({
page: currentPage,
pageSize: 10,
search,
})
return (
<div className="flex flex-col gap-4">
<PageHeader
title="Categories"
link="/inventory/categories/new"
data={categories}
/>
{categories.length === 0 && currentPage === 1 && (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-4">
No Categories found.
</div>
</div>
)}
{categories.length > 0 && (
<div className="overflow-x-auto">
<table className="text-muted-foreground w-full text-left text-sm">
<thead className="border-b">
<tr>
<th scope="col" className="p-4">
Name
</th>
<th scope="col" className="p-4">
Items
</th>
<th scope="col" className="p-4">
Actions
</th>
</tr>
</thead>
<tbody>
{categories.map((category) => (
<tr key={category.id} className="border-b">
<td className="p-4">{category.name}</td>
<td className="p-4">{category._count.items}</td>
<td className="flex items-center gap-2 p-4">
<Link
href={`/inventory/categories/${category.id}/edit`}
passHref
>
<Button
className="btn btn-primary"
variant="outline"
size="icon"
>
<Pencil />
</Button>
</Link>
{category._count.items === 0 && (
<DeleteCategoryButton categoryId={category.id} />
)}
</td>
</tr>
))}
</tbody>
<tfoot className="border-t">
<tr>
<td colSpan={3} className="p-4 text-center text-sm">
<PaginationButtons totalPages={totalPages} />
</td>
</tr>
</tfoot>
</table>
</div>
)}
</div>
)
}
@@ -0,0 +1,32 @@
import { CategoryService } from "@/services/category.service"
import { ItemService } from "@/services/item.service"
import UpdateItemForm from "../../_components/update.item.form"
export default async function AddItem({
params,
}: {
params: Promise<{ itemId: string }>
}) {
const { itemId } = await params
const categories = await CategoryService.findAll()
const item = await ItemService.findByIdWithAssetCount(itemId)
if (!item) {
return <div>Item not found</div>
}
return (
<div className="flex flex-col gap-4">
{item?._count?.assets && item?._count.assets > 0 && (
<div className="rounded-sm bg-red-100 p-4 text-red-800">
<p>{`This item has already assets assigned to it.`}</p>
</div>
)}
<div className="flex items-center justify-between gap-4">
<h1 className="text-2xl font-bold">Edit Item</h1>
</div>
<UpdateItemForm categories={categories} item={item} />
</div>
)
}
@@ -0,0 +1,100 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { AssetService } from "@/services/asset.service"
import { ItemService } from "@/services/item.service"
import { MovementService } from "@/services/movement.service"
export default async function ItemPage({
params,
}: {
params: Promise<{ itemId: string }>
}) {
const { itemId } = await params
const item = await ItemService.findByIdWithCategory(itemId)
const assets = await AssetService.findByItemId(itemId)
const movements = await MovementService.findAllByItemId(itemId)
if (!item) {
return <div>Item not found</div>
}
return (
<div className="grid gap-6">
<Card className="rounded-sm shadow-none">
<CardHeader>
<CardTitle>{item.name}</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-x-8 gap-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-600">Category</span>
<span>{item.category.name}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Stock</span>
<span>{item.stock}</span>
</div>
</div>
</CardContent>
</Card>
{assets?.length > 0 && (
<Card className="rounded-sm shadow-none">
<CardHeader>
<CardTitle>Assets</CardTitle>
</CardHeader>
<CardContent>
{assets?.map((asset) => (
<div
key={asset.id}
className="grid grid-cols-3 gap-x-8 gap-y-2 text-sm"
>
<div className="flex justify-between">
<span className="text-gray-600">Status</span>
<span>{asset.status || "Available"}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Serial Number</span>
<span>{asset.serialNumber}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Delivery Note</span>
<span>{asset.deliveryNote}</span>
</div>
</div>
))}
{assets?.length === 0 && (
<p className="col-span-2 text-center text-gray-500">
No assets found.
</p>
)}
</CardContent>
</Card>
)}
{movements?.length > 0 && (
<Card className="rounded-sm shadow-none">
<CardHeader>
<CardTitle>Movements</CardTitle>
</CardHeader>
<CardContent>
{movements.map((movement) => (
<div
key={`${movement.id}-${movement.type}`}
className="grid grid-cols-2 gap-x-8 gap-y-2 text-sm"
>
<div className="flex justify-between">
<span className="text-gray-600">Type</span>
<span>{movement.type}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Quantity</span>
<span>{movement.quantity}</span>
</div>
</div>
))}
</CardContent>
</Card>
)}
</div>
)
}
@@ -0,0 +1,47 @@
"use client"
import { Trash } from "lucide-react"
import { useRouter } from "next/navigation"
import { useTransition } from "react"
import { toast } from "sonner"
import { Button } from "@/components/ui/button"
import { deleteItemAction } from "@/lib/actions/item.actions"
export default function DeleteItemButton({ itemId }: { itemId: string }) {
const router = useRouter()
const [isPending, startTransition] = useTransition()
const handleDelete = (formData: FormData) => {
startTransition(async () => {
const response = await deleteItemAction(formData)
if (!response.success && response.errors?.id) {
toast.error(response.errors.id[0])
return
}
if (response.success) {
toast.success(response.message)
router.refresh()
} else {
toast.error(response.message ?? "Unknown error")
}
})
}
return (
<form action={handleDelete}>
<input type="hidden" name="id" value={itemId} />
<Button
type="submit"
className="btn btn-error"
size="icon"
variant="outline"
disabled={isPending}
>
<Trash />
</Button>
</form>
)
}
@@ -0,0 +1,126 @@
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { useRouter } from "next/navigation"
import { useForm } from "react-hook-form"
import { toast } from "sonner"
import { SubmitButton } from "@/components/forms/submitButton"
import { createItemAction } from "@/lib/actions/item.actions"
import {
CreateItemFormType,
createItemSchema,
} from "@/lib/schemas/item.schemas"
import { CategorySummary } from "@/lib/types"
export default function NewItemForm({
categories,
}: {
categories: CategorySummary[]
}) {
const router = useRouter()
const {
register,
handleSubmit,
setError,
formState: { errors, isSubmitting, isSubmitSuccessful },
} = useForm<CreateItemFormType>({
resolver: zodResolver(createItemSchema),
shouldFocusError: true,
mode: "onSubmit",
})
const onSubmit = async (formData: CreateItemFormType) => {
const response = await createItemAction(formData)
if (response?.errors) {
Object.entries(response.errors).forEach(([fieldName, messages]) => {
messages.forEach((msg: string) => {
setError(fieldName as keyof CreateItemFormType, {
type: "server",
message: msg,
})
toast.error(msg)
})
})
return
}
if (response?.success) {
toast.success(response.message)
router.push("/inventory/items ")
}
}
return (
<form className="flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)}>
<div>
<label htmlFor="name" className="mb-2 block text-lg">
Name
</label>
<input
type="text"
id="name"
placeholder="Item name"
{...register("name")}
className="w-full rounded-lg border px-4 py-2"
/>
{errors?.name && <p className="text-error">{errors.name.message}</p>}
</div>
<div>
<label htmlFor="categoryId" className="mb-2 block text-lg">
Category
</label>
<select
id="categoryId"
{...register("categoryId")}
className="w-full rounded-lg border px-4 py-2"
>
<option value="">Select a category</option>
{categories?.map((category) => (
<option key={category.id} value={category.id}>
{category.name}
</option>
))}
</select>
{errors?.categoryId && (
<p className="text-error">{errors.categoryId.message}</p>
)}
</div>
<div>
<label htmlFor="stock" className="mb-2 block text-lg">
Stock
</label>
<input
type="number"
id="stock"
pattern="{[0-9]*}"
placeholder="0"
min="0"
{...register("stock")}
className="w-full rounded-lg border px-4 py-2"
onKeyDownCapture={(event) => {
if (!/[0-9]/.test(event.key)) {
event.preventDefault()
}
if (event.key === "Backspace") {
event.preventDefault()
event.currentTarget.value = event.currentTarget.value.slice(
0,
event.currentTarget.value.length - 1,
)
}
}}
/>
{errors?.stock && <p className="text-error">{errors.stock.message}</p>}
</div>
<SubmitButton
isSubmitting={isSubmitting}
isSubmitSuccessful={isSubmitSuccessful}
>
Create Item
</SubmitButton>
</form>
)
}
@@ -0,0 +1,141 @@
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { useRouter } from "next/navigation"
import { useForm } from "react-hook-form"
import { toast } from "sonner"
import { SubmitButton } from "@/components/forms/submitButton"
import { updateItemAction } from "@/lib/actions/item.actions"
import {
UpdateItemFormType,
updateItemSchema,
} from "@/lib/schemas/item.schemas"
import { CategorySummary, ItemWithAssetCount } from "@/lib/types"
export default function UpdateItemForm({
categories,
item,
}: {
categories: CategorySummary[]
item: ItemWithAssetCount
}) {
const router = useRouter()
const isDisabled = !!item?._count.assets && item?._count.assets > 0
const {
register,
handleSubmit,
setError,
formState: { errors, isSubmitting, isSubmitSuccessful },
} = useForm<UpdateItemFormType>({
resolver: zodResolver(updateItemSchema),
defaultValues: {
id: item?.id,
name: item?.name,
categoryId: item?.category.id,
stock: item?.stock,
},
shouldFocusError: true,
mode: "onSubmit",
})
const onSubmit = async (formData: UpdateItemFormType) => {
const response = await updateItemAction(formData)
if (response?.errors) {
Object.entries(response.errors).forEach(([fieldName, messages]) => {
messages.forEach((msg: string) => {
setError(fieldName as keyof UpdateItemFormType, {
type: "server",
message: msg,
})
toast.error(msg)
})
})
return
}
if (response?.success) {
toast.success(response.message)
router.push("/inventory/items ")
}
}
return (
<form className="flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)}>
{item?.id && <input type="hidden" name="id" value={item.id} />}
<div>
<label htmlFor="name" className="mb-2 block text-lg">
Name
</label>
<input
type="text"
id="name"
placeholder="Item name"
{...register("name")}
className={`w-full rounded-lg border px-4 py-2`}
/>
{errors?.name && <p className="text-error">{errors.name.message}</p>}
</div>
<div>
<label htmlFor="categoryId" className="mb-2 block text-lg">
Category
</label>
<select
id="categoryId"
// disabled={isDisabled}
{...register("categoryId")}
className={`w-full rounded-lg border px-4 py-2`}
>
<option value="">Select a category</option>
{categories?.map((category) => (
<option key={category.id} value={category.id}>
{category.name}
</option>
))}
</select>
{errors?.categoryId && (
<p className="text-error">{errors.categoryId.message}</p>
)}
</div>
<div>
<label htmlFor="stock" className="mb-2 block text-lg">
Stock
</label>
<input
type="number"
id="stock"
pattern="{[0-9]*}"
placeholder="0"
min={item.stock}
disabled={isDisabled}
{...register("stock")}
className={`w-full rounded-lg border px-4 py-2 ${
isDisabled ? "bg-gray-100" : ""
}`}
onKeyDownCapture={(event) => {
if (!/[0-9]/.test(event.key)) {
event.preventDefault()
}
if (event.key === "Backspace") {
event.preventDefault()
event.currentTarget.value = event.currentTarget.value.slice(
0,
event.currentTarget.value.length - 1,
)
}
}}
/>
{errors?.stock && <p className="text-error">{errors.stock.message}</p>}
</div>
<SubmitButton
isSubmitting={isSubmitting}
isSubmitSuccessful={isSubmitSuccessful}
>
Update Item
</SubmitButton>
</form>
)
}
@@ -0,0 +1,16 @@
import { CategoryService } from "@/services/category.service"
import NewItemForm from "../_components/new.item.form"
export default async function NewItemPage() {
const categories = await CategoryService.findAll()
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-4">
<h1 className="text-2xl font-bold">New Item</h1>
</div>
<NewItemForm categories={categories} />
</div>
)
}
@@ -0,0 +1,100 @@
import { Eye, Pencil } from "lucide-react"
import Link from "next/link"
import PageHeader from "@/components/common/pageheader"
import PaginationButtons from "@/components/common/pagination"
import { Button } from "@/components/ui/button"
import { ItemService } from "@/services/item.service"
import DeleteItemButton from "./_components/delete.item.button"
export default async function ItemsPage(props: {
searchParams?: Promise<{
page?: string
search?: string
}>
}) {
const searchParams = await props.searchParams
const currentPage = searchParams?.page ? parseInt(searchParams.page) : 1
const search = searchParams?.search || ""
const { data: items, totalPages } = await ItemService.findAllWithAssetCount({
page: currentPage,
pageSize: 10,
search,
})
return (
<div className="flex flex-col gap-4">
<PageHeader
title="Items"
link="/inventory/items/new"
data={items}
search={search}
/>
{items.length === 0 && currentPage === 1 && (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-4">
No items found.
</div>
</div>
)}
{items.length > 0 && (
<div className="overflow-x-auto">
<table className="text-muted-foreground w-full text-left text-sm">
<thead className="border-b">
<tr>
<th scope="col" className="p-4">
Name
</th>
<th scope="col" className="p-4">
Category
</th>
<th scope="col" className="p-4">
Assets
</th>
<th scope="col" className="p-4">
Stock
</th>
<th scope="col" className="p-4">
Actions
</th>
</tr>
</thead>
<tbody>
{items.map((item) => (
<tr key={item.id} className="border-b">
<td className="p-4">{item.name}</td>
<td className="p-4">{item.category.name}</td>
<td className="p-4">{item._count.assets}</td>
<td className="p-4">{item.stock}</td>
<td className="flex items-center gap-2 p-4">
<Link href={`/inventory/items/${item.id}`} passHref>
<Button variant="outline" size="icon">
<Eye />
</Button>
</Link>
<Link href={`/inventory/items/${item.id}/edit`} passHref>
<Button variant="outline" size="icon">
<Pencil />
</Button>
</Link>
{item._count.assets === 0 && item.stock === 0 && (
<DeleteItemButton itemId={item.id} />
)}
</td>
</tr>
))}
</tbody>
<tfoot className="border-t">
<tr>
<td colSpan={5} className="p-4 text-center text-sm">
<PaginationButtons totalPages={totalPages} />
</td>
</tr>
</tfoot>
</table>
</div>
)}
</div>
)
}