first version
This commit is contained in:
+27
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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} />
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user