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
+27
View File
@@ -0,0 +1,27 @@
import Link from "next/link"
export default function Card({
title,
total,
icon,
href,
}: {
title: string
total: number
icon: React.ReactNode
href: string
}) {
return (
<Link href={href} passHref>
<div className="rounded-lg border bg-white p-6 shadow-sm transition-shadow hover:shadow">
<div className="flex items-center">
<div className="mr-4">{icon}</div>
<div>
<h3 className="text-lg font-medium">{title}</h3>
<p className="text-muted-foreground mt-2 text-sm">Total: {total}</p>
</div>
</div>
</div>
</Link>
)
}
+82
View File
@@ -0,0 +1,82 @@
import { AssetService } from "@/services/asset.service"
import { ItemService } from "@/services/item.service"
import { RecipientService } from "@/services/recipient.service"
import Card from "./_components/card"
export default async function Home() {
const totalItems = await ItemService.findAllItemsCount()
const totalAssets = await AssetService.findAllAssetsCount()
const totalRecipients = await RecipientService.findAllRecipientsCount()
return (
<div className="container mx-auto p-4">
<h1 className="mb-4 text-2xl font-bold">Dashboard</h1>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<Card
title="Total Items"
total={totalItems}
href="/inventory/items"
icon={
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-6 w-6 text-blue-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 7v4a1 1 0 001 1h3m10-5h3a1 1 0 011 1v4m-5 5l-5 5m0 0l-5-5m5 5V2"
/>
</svg>
}
/>
<Card
title="Total Assets"
total={totalAssets}
href="/inventory/assets"
icon={
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-6 w-6 text-green-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-4m-4 0H4"
/>
</svg>
}
/>
<Card
title="Total Recipients"
total={totalRecipients}
href="/recipients"
icon={
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-6 w-6 text-red-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5.121 17.804A13.937 13.937 0 0112 16c2.5 0 4.847.655 6.879 1.804M15 10a3 3 0 11-6 0 3 3 0 016 0zm6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
}
/>
</div>
</div>
)
}
@@ -0,0 +1,33 @@
import { UpdateAssignmentFormType } from "@/lib/schemas/assignment.schemas"
import { AssetService } from "@/services/asset.service"
import { AssignmentService } from "@/services/assignment.service"
import { ItemService } from "@/services/item.service"
import { RecipientService } from "@/services/recipient.service"
import AssignmentForm from "../../_components/edit.assignment.form"
export default async function EditAssignmentPage({
params,
}: {
params: Promise<{ assignamentId: string }>
}) {
const { assignamentId } = await params
const assignment = await AssignmentService.findById(assignamentId)
const recipients = await RecipientService.findAll()
const items = await ItemService.findAllWithStock()
const assets = await AssetService.findAll()
if (!assignment) {
return <div>Assignment not found</div>
}
return (
<div>
<AssignmentForm
recipients={recipients}
items={items}
assets={assets}
initialData={assignment as UpdateAssignmentFormType}
/>
</div>
)
}
@@ -0,0 +1,161 @@
"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 { updateAssignment } from "@/lib/actions/assignament.actions"
import {
UpdateAssignmentFormType,
updateAssignmentSchema,
} from "@/lib/schemas/assignment.schemas"
import { Asset, Item, Recipient } from "@/lib/types"
interface Props {
recipients: Recipient[]
items: Item[]
assets: Asset[]
initialData: UpdateAssignmentFormType
}
export default function EditAssignmentForm({
recipients,
items,
assets,
initialData,
}: Props) {
const router = useRouter()
const {
register,
handleSubmit,
formState: { errors, isSubmitting, isSubmitSuccessful },
watch,
} = useForm<UpdateAssignmentFormType>({
resolver: zodResolver(updateAssignmentSchema),
defaultValues: {
...initialData,
id: initialData.id || undefined,
},
mode: "onSubmit",
})
const itemId = watch("itemId")
const assetId = watch("assetId")
const onSubmit = async (formData: UpdateAssignmentFormType) => {
const response = await updateAssignment(formData)
if (response?.errors) {
Object.values(response.errors as Record<string, string[]>).forEach(
(messages) => {
messages.forEach((msg) => toast.error(msg))
},
)
return
}
if (response?.success) {
toast.success(response.message)
router.push("/assignments")
}
}
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="recipientId" className="mb-2 block text-lg">
Recipient
</label>
<select
id="recipientId"
{...register("recipientId")}
className={`w-full rounded-lg border px-4 py-2 ${
errors.recipientId ? "border-error" : ""
}`}
>
{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>
<div className="flex flex-col gap-2">
<label htmlFor="itemId" className="mb-2 block text-lg">
Item
</label>
<select
id="itemId"
{...register("itemId")}
className={`w-full rounded-lg border px-4 py-2 ${
errors.itemId ? "border-error" : ""
}`}
>
{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 className="flex flex-col gap-2">
<label htmlFor="assetId" className="mb-2 block text-lg">
Asset
</label>
<select
id="assetId"
{...register("assetId")}
className={`w-full rounded-lg border px-4 py-2 ${
errors.assetId ? "border-error" : ""
}`}
>
<option value="">Select an asset</option>
{itemId
? assets.map((asset) => (
<option key={asset.id} value={asset.id}>
{asset.serialNumber}
</option>
))
: null}
</select>
{errors.assetId && (
<p className="text-error">{errors.assetId.message}</p>
)}
</div>
<div className="flex flex-col gap-2">
<label htmlFor="quantity" className="mb-2 block text-lg">
Quantity
</label>
<input
type="number"
id="quantity"
disabled={!itemId || assets.length > 0}
min={1}
max={itemId ? items.find((item) => item.id === itemId)?.stock : 0}
defaultValue={1}
{...register("quantity")}
className={`w-full rounded-lg border px-4 py-2 ${
!itemId || assets.length > 0 ? "border-gray-300 bg-gray-100" : ""
}`}
/>
{errors.quantity && (
<p className="text-error">{errors.quantity.message}</p>
)}
</div>
<SubmitButton
isSubmitting={isSubmitting}
isSubmitSuccessful={isSubmitSuccessful}
disabled={!itemId || (assets.length > 0 && !assetId)}
>
Update Assignment
</SubmitButton>
</form>
)
}
@@ -0,0 +1,168 @@
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { useRouter } from "next/navigation"
import { useMemo } from "react"
import { useForm } from "react-hook-form"
import { toast } from "sonner"
import { SubmitButton } from "@/components/forms/submitButton"
import { createAssignment } from "@/lib/actions/assignament.actions"
import {
CreateAssignmentFormType,
createAssignmentSchema,
} from "@/lib/schemas/assignment.schemas"
import { Asset, Item, Recipient } from "@/lib/types"
interface Props {
recipients: Recipient[]
items: Item[]
assets: Asset[]
}
export default function CreateAssignmentForm({
recipients,
items,
assets,
}: Props) {
const router = useRouter()
const {
register,
handleSubmit,
formState: { errors, isSubmitting, isSubmitSuccessful },
watch,
} = useForm<CreateAssignmentFormType>({
resolver: zodResolver(createAssignmentSchema),
mode: "onSubmit",
})
const itemId = watch("itemId")
const assetId = watch("assetId")
const itemAssets = useMemo(() => {
return assets.filter((asset) => asset.itemId === itemId)
}, [assets, itemId])
const onSubmit = async (formData: CreateAssignmentFormType) => {
const response = await createAssignment(formData)
if (response?.errors) {
Object.values(response.errors as Record<string, string[]>).forEach(
(messages) => {
messages.forEach((msg) => toast.error(msg))
},
)
return
}
if (response?.success) {
toast.success(response.message)
router.push("/assignments")
}
}
return (
<form className="flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)}>
<div className="flex flex-col gap-2">
<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 ${
errors.recipientId ? "border-error" : ""
}`}
>
<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>
<div className="flex flex-col gap-2">
<label htmlFor="itemId" className="mb-2 block text-lg">
Item
</label>
<select
id="itemId"
{...register("itemId")}
className={`w-full rounded-lg border px-4 py-2 ${
errors.itemId ? "border-error" : ""
}`}
>
<option value="">Select an 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>
{itemId && itemAssets.length !== 0 && (
<div className="flex flex-col gap-2">
<label htmlFor="assetId" className="mb-2 block text-lg">
Asset
</label>
<select
id="assetId"
{...register("assetId")}
disabled={!itemId || itemAssets.length === 0}
className={`w-full rounded-lg border px-4 py-2 ${
!itemId || itemAssets.length === 0
? "border-gray-300 bg-gray-100"
: ""
}`}
>
<option value="">Select an asset</option>
{itemId
? itemAssets.map((asset) => (
<option key={asset.id} value={asset.id}>
{asset.serialNumber}
</option>
))
: null}
</select>
{errors.assetId && (
<p className="text-error">{errors.assetId.message}</p>
)}
</div>
)}
<div className="flex flex-col gap-2">
<label htmlFor="quantity" className="mb-2 block text-lg">
Quantity
</label>
<input
type="number"
id="quantity"
disabled={!itemId || itemAssets.length > 0}
min={1}
max={itemId ? items.find((item) => item.id === itemId)?.stock : 0}
defaultValue={1}
{...register("quantity")}
className={`w-full rounded-lg border px-4 py-2 ${
!itemId || itemAssets.length > 0
? "border-gray-300 bg-gray-100"
: ""
}`}
/>
{errors.quantity && (
<p className="text-error">{errors.quantity.message}</p>
)}
</div>
<SubmitButton
isSubmitting={isSubmitting}
isSubmitSuccessful={isSubmitSuccessful}
disabled={!itemId || (itemAssets.length > 0 && !assetId)}
>
Create Assignment
</SubmitButton>
</form>
)
}
@@ -0,0 +1,52 @@
"use client"
import { ArrowLeft } from "lucide-react"
import { useRouter } from "next/navigation"
import { useTransition } from "react"
import { toast } from "sonner"
import { Button } from "@/components/ui/button"
import { returnAssignment } from "@/lib/actions/assignament.actions"
import { ReturnAssignmentFormType } from "@/lib/schemas/assignment.schemas"
export default function ReturnButton({
assignmentId,
}: {
assignmentId: string
}) {
const router = useRouter()
const [isPending, startTransition] = useTransition()
const handleReturn = (formData: ReturnAssignmentFormType) => {
startTransition(async () => {
const response = await returnAssignment(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={() => handleReturn({ id: assignmentId })} className="w-full">
<input type="hidden" name="id" value={assignmentId} />
<Button
type="submit"
className="btn btn-error"
size="icon"
variant="outline"
disabled={isPending}
>
<ArrowLeft />
</Button>
</form>
)
}
@@ -0,0 +1,15 @@
import { AssetService } from "@/services/asset.service"
import { ItemService } from "@/services/item.service"
import { RecipientService } from "@/services/recipient.service"
import AssignmentForm from "../_components/new.assignment.form"
export default async function NewAssignmentPage() {
const recipients = await RecipientService.findAll()
const items = await ItemService.findAllWithStock()
const assets = await AssetService.findAllAvailable()
return (
<AssignmentForm recipients={recipients} items={items} assets={assets} />
)
}
+105
View File
@@ -0,0 +1,105 @@
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 { AssignmentService } from "@/services/assignment.service"
import ReturnButton from "./_components/return.button"
export default async function AssignmentsPage(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: assignments, totalPages } =
await AssignmentService.findAllWithRecipient({
page: currentPage,
search,
})
return (
<div className="flex flex-col gap-4">
<PageHeader
title="Assignments"
link="/assignments/new"
search={search}
data={assignments}
/>
{assignments.length === 0 && <div>No assignments found</div>}
{assignments.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">
Recipient
</th>
<th scope="col" className="p-4">
Item
</th>
<th scope="col" className="p-4">
Serial Number
</th>
<th scope="col" className="p-4">
Actions
</th>
</tr>
</thead>
<tbody>
{assignments.map((assignment) => (
<tr key={assignment.id} className="border-b">
<td className="p-4">
<Link
href={`/recipients/${assignment?.recipient?.id}`}
className="hover:underline"
>
{assignment?.recipient?.firstName}{" "}
{assignment?.recipient?.lastName}
</Link>
</td>
<td className="p-4">
<Link
href={`/inventory/items/${assignment?.item?.id}`}
className="hover:underline"
>
{assignment?.item?.name}
</Link>
</td>
<td className="p-4">
{assignment?.asset?.serialNumber || "N/A"}
</td>
<td className="p-4">
<div className="flex gap-2">
<Link
href={`/assignments/${assignment.id}/edit`}
passHref
>
<Button variant="outline">
<Pencil />
</Button>
</Link>
<ReturnButton assignmentId={assignment.id} />
</div>
</td>
</tr>
))}
</tbody>
<tfoot className="border-t">
<tr>
<td colSpan={4} className="p-4 text-center text-sm">
<PaginationButtons totalPages={totalPages} />
</td>
</tr>
</tfoot>
</table>
</div>
)}
</div>
)
}
@@ -0,0 +1,107 @@
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { useRouter } from "next/navigation"
import { ChangeEvent } from "react"
import { useForm } from "react-hook-form"
import { toast } from "sonner"
import { SubmitButton } from "@/components/forms/submitButton"
import { importItems } from "@/lib/actions/import.actions"
import { ImportFormType, importSchema } from "@/lib/schemas/import.schemas"
import { CategorySummary } from "@/lib/types"
export default function ImportForm({
categories,
}: {
categories: CategorySummary[]
}) {
const router = useRouter()
const {
register,
handleSubmit,
setValue,
setError,
formState: { errors, isSubmitting, isSubmitSuccessful },
watch,
} = useForm<ImportFormType>({
resolver: zodResolver(importSchema),
mode: "onSubmit",
})
const file = watch("file")
const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
const selectedFile = event.target.files?.[0]
if (selectedFile) {
setValue("file", selectedFile, { shouldValidate: true })
}
}
const onSubmit = async (formData: ImportFormType) => {
const response = await importItems(formData)
if (response?.errors) {
Object.entries(response.errors).forEach(([fieldName, messages]) => {
messages.forEach((msg: string) => {
setError(fieldName as keyof ImportFormType, {
type: "server",
message: msg,
})
toast.error(msg)
})
})
return
}
if (response?.success) {
toast.success(response?.message || "Asset created successfully")
router.push("/inventory/items")
}
}
return (
<form className="flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)}>
<div>
<label htmlFor="file" className="mb-2 block text-lg">
File
</label>
<input
type="file"
accept=".csv"
onChange={handleFileChange}
className="w-full rounded-lg border px-4 py-2"
/>
{errors?.file && <p className="text-error">{errors.file.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>
<SubmitButton
isSubmitting={isSubmitting}
isSubmitSuccessful={isSubmitSuccessful}
disabled={!file}
>
Import
</SubmitButton>
</form>
)
}
+27
View File
@@ -0,0 +1,27 @@
import { Download } from "lucide-react"
import Link from "next/link"
import { Button } from "@/components/ui/button"
import { CategoryService } from "@/services/category.service"
import ImportForm from "./_components/import.form"
export default async function ImportPage() {
const categories = await CategoryService.findAllWithItemsCount()
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-4">
<h1 className="text-2xl font-bold">Mass Import</h1>
</div>
<div className="flex items-center justify-end gap-4">
<Link href="/template.csv" download>
<Button variant="outline">
<Download />
Download Template
</Button>
</Link>
</div>
<ImportForm categories={categories} />
</div>
)
}
@@ -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>
)
}
+22
View File
@@ -0,0 +1,22 @@
import { Toaster } from "sonner"
import Navbar from "@/components/layout/navbar"
import AppSidebar from "@/components/layout/sidebar"
import { SidebarProvider } from "@/components/ui/sidebar"
export default async function LayoutDashboard({
children,
}: {
children: React.ReactNode
}) {
return (
<SidebarProvider>
<AppSidebar />
<main className="w-full">
<Navbar />
<div className="flex-1 p-6">{children}</div>
</main>
<Toaster />
</SidebarProvider>
)
}
+77
View File
@@ -0,0 +1,77 @@
import PaginationButtons from "@/components/common/pagination"
import { formatDate } from "@/lib/utils"
import { MovementService } from "@/services/movement.service"
export default async function MovementsPage(props: {
searchParams?: Promise<{
page?: string
}>
}) {
const searchParams = await props.searchParams
const currentPage = searchParams?.page ? parseInt(searchParams.page) : 1
const { data: movements, totalPages } = await MovementService.findAll({
page: currentPage,
pageSize: 12,
})
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-4">
<h1 className="text-2xl font-bold">Movements</h1>
</div>
{movements.length === 0 && <div>No movements found</div>}
{movements.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">
Type
</th>
<th scope="col" className="p-4">
Item
</th>
<th scope="col" className="p-4">
Serial Number
</th>
<th scope="col" className="p-4">
Quantity
</th>
<th scope="col" className="p-4">
Recipient
</th>
<th scope="col" className="p-4">
Date
</th>
</tr>
</thead>
<tbody>
{movements.map((movement) => (
<tr key={movement.id} className="border-b">
<td className="p-4">{movement.type}</td>
<td className="p-4">{movement?.item?.name}</td>
<td className="p-4">
{movement?.asset?.serialNumber || "-"}
</td>
<td className="p-4">{movement.quantity}</td>
<td className="p-4">
{movement?.recipient?.firstName || "-"}{" "}
{movement?.recipient?.lastName || "-"}
</td>
<td className="p-4">{formatDate(movement.createdAt)}</td>
</tr>
))}
</tbody>
<tfoot className="border-t">
<tr>
<td colSpan={6} className="p-4 text-center text-sm">
<PaginationButtons totalPages={totalPages} />
</td>
</tr>
</tfoot>
</table>
</div>
)}
</div>
)
}
@@ -0,0 +1,25 @@
import { RecipientService } from "@/services/recipient.service"
import RecipientForm from "../../_components/recipient.form"
export default async function RecipientEditPage({
params,
}: {
params: Promise<{ recipientId: string }>
}) {
const { recipientId } = await params
const recipient = await RecipientService.findById(recipientId)
if (!recipient) {
return <div>Recipient 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 Recipient</h1>
</div>
<RecipientForm initialData={recipient} mode="edit" />
</div>
)
}
@@ -0,0 +1,70 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { AssignmentService } from "@/services/assignment.service"
import { RecipientService } from "@/services/recipient.service"
export default async function RecipientInfoPage({
params,
}: {
params: Promise<{ recipientId: string }>
}) {
const { recipientId } = await params
const recipient = await RecipientService.findById(recipientId)
const assignments = await AssignmentService.findAllByRecipient(recipientId)
if (!recipient) {
return <div>Recipient not found</div>
}
return (
<div className="grid gap-6">
<Card className="rounded-sm shadow-none">
<CardHeader>
<CardTitle>
{recipient.firstName + " " + recipient.lastName}
</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">Username</span>
<span>{recipient.username}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Email</span>
<span>{recipient.email}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Phone</span>
<span>{recipient.phone}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Department</span>
<span>{recipient.department}</span>
</div>
</div>
</CardContent>
</Card>
{assignments.length > 0 && (
<Card className="rounded-sm shadow-none">
<CardHeader>
<CardTitle>Assignments</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-y-2 text-sm">
{assignments.map((assignment) => (
<div
key={assignment.id}
className="flex w-full justify-between"
>
<span className="text-gray-600">{assignment.item?.name}</span>
<span>{assignment.asset?.serialNumber}</span>
<span>{assignment.quantity || 1}</span>
</div>
))}
</div>
</CardContent>
</Card>
)}
</div>
)
}
@@ -0,0 +1,177 @@
"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 { RecipientDepartment } from "@/generated/prisma/client"
import {
createNewRecipient,
updateRecipient,
} from "@/lib/actions/recipient.actions"
import {
CreateRecipientFormType,
recipientSchema,
UpdateRecipientFormType,
} from "@/lib/schemas/recipients.schemas"
import { Recipient } from "@/lib/types"
interface RecipientFormProps {
initialData?: Recipient
mode?: "create" | "edit"
}
export default function RecipientForm({
initialData,
mode = "create",
}: RecipientFormProps) {
const router = useRouter()
const {
register,
handleSubmit,
setError,
formState: { errors, isSubmitting, isSubmitSuccessful },
} = useForm<CreateRecipientFormType>({
resolver: zodResolver(recipientSchema),
defaultValues: {
id: initialData?.id || "",
username: initialData?.username || "",
firstName: initialData?.firstName || "",
lastName: initialData?.lastName || "",
department: initialData?.department || "OTHER",
email: initialData?.email || "",
phone: initialData?.phone || "",
},
})
const onSubmit = async (formData: CreateRecipientFormType) => {
const response =
mode === "create"
? await createNewRecipient(formData)
: await updateRecipient(formData as UpdateRecipientFormType)
if (response?.errors) {
Object.entries(response.errors).forEach(([fieldName, messages]) => {
messages.forEach((msg: string) => {
setError(fieldName as keyof CreateRecipientFormType, {
type: "server",
message: msg,
})
toast.error(msg)
})
})
return
}
if (response?.success) {
toast.success(response.message)
router.push("/recipients")
}
}
return (
<form className="flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)}>
<input type="hidden" {...register("id")} />
<div>
<label htmlFor="username" className="mb-2 block text-lg">
Username
</label>
<input
type="text"
id="username"
placeholder="Username"
{...register("username")}
className={`w-full rounded-lg border px-4 py-2`}
/>
{errors?.username && (
<p className="text-error">{errors.username.message}</p>
)}
</div>
<div>
<label htmlFor="firstName" className="mb-2 block text-lg">
First Name
</label>
<input
type="text"
id="firstName"
placeholder="First Name"
{...register("firstName")}
className={`w-full rounded-lg border px-4 py-2`}
/>
{errors?.firstName && (
<p className="text-error">{errors.firstName.message}</p>
)}
</div>
<div>
<label htmlFor="lastName" className="mb-2 block text-lg">
Last Name
</label>
<input
type="text"
id="lastName"
placeholder="Last Name"
{...register("lastName")}
className={`w-full rounded-lg border px-4 py-2`}
/>
{errors?.lastName && (
<p className="text-error">{errors.lastName.message}</p>
)}
</div>
<div>
<label htmlFor="department" className="mb-2 block text-lg">
Department
</label>
<select
id="department"
{...register("department")}
className="w-full rounded-lg border px-4 py-2"
>
<option value="">Select a department</option>
{Object.keys(RecipientDepartment).map((department) => (
<option key={department} value={department}>
{department}
</option>
))}
</select>
{errors?.department && (
<p className="text-error">{errors.department.message}</p>
)}
</div>
<div>
<label htmlFor="email" className="mb-2 block text-lg">
Email
</label>
<input
type="text"
id="email"
placeholder="Email"
{...register("email")}
className={`w-full rounded-lg border px-4 py-2`}
/>
{errors?.email && <p className="text-error">{errors.email.message}</p>}
</div>
<div>
<label htmlFor="phone" className="mb-2 block text-lg">
Phone
</label>
<input
type="text"
id="phone"
placeholder="Phone"
{...register("phone")}
className={`w-full rounded-lg border px-4 py-2`}
/>
{errors?.phone && <p className="text-error">{errors.phone.message}</p>}
</div>
<SubmitButton
isSubmitting={isSubmitting}
isSubmitSuccessful={isSubmitSuccessful}
>
{mode === "create" ? "Create Recipient" : "Update Recipient"}
</SubmitButton>
</form>
)
}
@@ -0,0 +1,12 @@
import RecipientForm from "../_components/recipient.form"
export default function NewRecipientPage() {
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-4">
<h1 className="text-2xl font-bold">Add Recipient</h1>
</div>
<RecipientForm mode="create" />
</div>
)
}
+101
View File
@@ -0,0 +1,101 @@
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 { Recipient } from "@/generated/prisma/client"
import { RecipientService } from "@/services/recipient.service"
export default async function RecipientsPage(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: recipients, totalPages } =
await RecipientService.findAllPaginated({
page: currentPage,
pageSize: 10,
search,
})
return (
<div className="flex flex-col gap-4">
<PageHeader
title="Recipients"
link="/recipients/new"
data={recipients}
search={search}
/>
{recipients.length === 0 && <div>No recipients found</div>}
{recipients.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">
Username
</th>
<th scope="col" className="p-4">
Name
</th>
<th scope="col" className="p-4">
Email
</th>
<th scope="col" className="p-4">
Phone
</th>
<th scope="col" className="p-4">
Department
</th>
<th scope="col" className="p-4">
Actions
</th>
</tr>
</thead>
<tbody>
{recipients.map((recipient: Recipient) => (
<tr key={recipient.id} className="border-b">
<td className="p-4">{recipient.username}</td>
<td className="p-4">
{recipient.firstName + " " + recipient.lastName}
</td>
<td className="p-4">{recipient.email}</td>
<td className="p-4">{recipient.phone}</td>
<td className="p-4">{recipient.department}</td>
<td className="flex items-center gap-2 p-4">
<Link href={`/recipients/${recipient.id}`} passHref>
<Button variant="outline" size="icon">
<Eye />
</Button>
</Link>
<Link href={`/recipients/${recipient.id}/edit`} passHref>
<Button
className="btn btn-primary"
variant="outline"
size="icon"
>
<Pencil />
</Button>
</Link>
</td>
</tr>
))}
</tbody>
<tfoot className="border-t">
<tr>
<td colSpan={6} className="p-4 text-center text-sm">
<PaginationButtons totalPages={totalPages} />
</td>
</tr>
</tfoot>
</table>
</div>
)}
</div>
)
}