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
+198
View File
@@ -0,0 +1,198 @@
"use server"
import { revalidatePath } from "next/cache"
import {
createAssignment,
updateAssignment,
} from "@/lib/actions/assignament.actions"
import {
CreateAssetFormType,
createAssetSchema,
UpdateAssetFormType,
updateAssetSchema,
} from "@/lib/schemas/asset.schemas"
import { AssetService } from "@/services/asset.service"
import { AssignmentService } from "@/services/assignment.service"
import { ItemService } from "@/services/item.service"
import { MovementService } from "@/services/movement.service"
export async function createAssetAction(formData: CreateAssetFormType) {
try {
const validatedFields = createAssetSchema.safeParse(formData)
if (!validatedFields.success) {
return {
errors: validatedFields.error.flatten().fieldErrors,
}
}
const { itemId, serialNumber, deliveryNote, status, notes, recipientId } =
validatedFields.data
const item = await ItemService.findByIdWithCategory(itemId)
if (!item) {
return {
success: false,
errors: { itemId: ["Item not found"] },
}
}
const existentAsset = await AssetService.findBySerialNumber(serialNumber)
if (existentAsset) {
return {
success: false,
errors: {
serialNumber: ["This serial number already exists"],
},
}
}
const newAsset = await AssetService.create({
item: { connect: { id: itemId } },
serialNumber,
deliveryNote,
status,
notes,
})
await MovementService.create({
itemId,
assetId: newAsset?.id,
quantity: 1,
type: status === "ASSIGNED" ? "ASSIGNMENT" : "IN",
})
if (status === "AVAILABLE") {
await ItemService.update(itemId, {
stock: item.stock + 1,
name: item.name,
category: { connect: { id: item.category?.id } },
})
}
if (status === "ASSIGNED" && recipientId) {
await AssignmentService.create({
notes: "",
itemId,
assetId: newAsset?.id,
quantity: 1,
recipientId,
assignmentDate: new Date(),
})
}
revalidatePath("/inventory/assets")
return {
success: true,
message: "Asset created successfully",
}
} catch (error) {
console.error("Database error:", error)
return {
success: false,
message: "Error creating asset",
}
}
}
export async function updateAssetAction(formData: UpdateAssetFormType) {
const validatedFields = updateAssetSchema.safeParse(formData)
if (!validatedFields.success) {
return {
errors: validatedFields.error.flatten().fieldErrors,
}
}
const { id, itemId, serialNumber, deliveryNote, status, notes, recipientId } =
validatedFields.data
try {
const item = await ItemService.findByIdWithCategory(itemId)
if (!item) {
return {
success: false,
errors: { itemId: ["Item not found"] },
}
}
const existentAsset = await AssetService.findBySerialNumber(serialNumber)
if (
existentAsset &&
id !== existentAsset.id &&
existentAsset.serialNumber === serialNumber
) {
return {
success: false,
errors: {
serialNumber: ["This serial number already exists"],
},
}
}
await AssetService.update(id, {
item: { connect: { id: itemId } },
serialNumber,
deliveryNote,
status,
notes,
})
await MovementService.create({
itemId,
assetId: id,
quantity: 1,
type: status === "ASSIGNED" ? "ASSIGNMENT" : "IN",
})
if (status === "AVAILABLE") {
await ItemService.update(itemId, {
stock: item.stock + 1,
name: item.name,
category: { connect: { id: item.category?.id } },
})
}
if (status === "ASSIGNED" && recipientId) {
if (!recipientId) {
return {
success: false,
errors: { recipientId: ["Recipient is required for assignment"] },
}
}
if (existentAsset?.assignment) {
await updateAssignment({
id: existentAsset.assignment.id,
recipientId,
quantity: 1,
})
} else {
await createAssignment({
itemId,
assetId: id,
quantity: 1,
recipientId,
assignmentDate: new Date(),
})
}
}
return {
success: true,
message: "Asset updated successfully",
}
} catch (error) {
console.error("Database error:", error)
return {
success: false,
message: "Error updating asset",
}
}
}
+263
View File
@@ -0,0 +1,263 @@
"use server"
import { revalidatePath } from "next/cache"
import prisma from "@/lib/prisma"
import { AssetService } from "@/services/asset.service"
import { AssignmentService } from "@/services/assignment.service"
import { getAuthenticatedUserId } from "@/services/auth.service"
import { ItemService } from "@/services/item.service"
import { MovementService } from "@/services/movement.service"
import {
assignmentSchema,
CreateAssignmentFormType,
ReturnAssignmentFormType,
UpdateAssignmentFormType,
updateAssignmentSchema,
} from "../schemas/assignment.schemas"
export async function getAssignments() {
return await AssignmentService.findAllWithRecipient()
}
export async function getAssignmentById(id: string) {
return await AssignmentService.findById(id)
}
export async function createAssignment(formData: CreateAssignmentFormType) {
const createdBy = await getAuthenticatedUserId()
const validatedFields = assignmentSchema.safeParse({
...formData,
createdBy,
})
if (!validatedFields.success) {
return {
success: false,
errors: validatedFields.error.flatten().fieldErrors,
}
}
const { itemId, assetId, quantity, recipientId } = validatedFields.data
if (!itemId || !recipientId || quantity <= 0) return
try {
const item = await ItemService.findById(itemId)
if (!item) {
return {
success: false,
errors: { itemId: ["Item not found"] },
}
}
if (item.stock < quantity) {
return {
success: false,
errors: { quantity: ["Item does not have enough stock"] },
}
}
if (assetId) {
const asset = await AssetService.findById(assetId)
if (!asset) {
return {
success: false,
errors: { assetId: ["Asset not found"] },
}
}
if (asset.itemId !== item.id) {
return {
success: false,
errors: { assetId: ["Asset does not belong to item"] },
}
}
}
await ItemService.updateStock(itemId, -quantity)
if (assetId && recipientId) {
await AssetService.update(assetId, {
status: "ASSIGNED",
})
}
const createdAssignment = await AssignmentService.create({
itemId,
assetId: assetId || undefined,
quantity,
recipientId,
})
await MovementService.create({
itemId,
assetId: assetId || undefined,
quantity,
type: "ASSIGNMENT",
recipientId,
assignmentId: createdAssignment.id,
})
revalidatePath("/assignments")
return {
success: true,
message: "Assignment created successfully",
}
} catch (error) {
console.error("Database error:", error)
return {
success: false,
errors: { error: ["Error creating assignment"] },
}
}
}
export async function updateAssignment(formData: UpdateAssignmentFormType) {
const validatedFields = updateAssignmentSchema.safeParse(formData)
if (!validatedFields.success) {
return {
success: false,
errors: validatedFields.error.flatten().fieldErrors,
}
}
try {
const { id, itemId, assetId, quantity } = validatedFields.data
const assignment = await prisma.assignment.findUnique({
where: { id },
})
if (!assignment) {
return {
success: false,
errors: { id: ["Assignment not found"] },
}
}
if (itemId) {
const item = await prisma.item.findUnique({
where: { id: itemId },
})
if (!item) {
return {
success: false,
errors: { itemId: ["Item not found"] },
}
}
if (item.stock < quantity) {
return {
success: false,
errors: { quantity: ["Item does not have enough stock"] },
}
}
}
if (assetId) {
const asset = await prisma.asset.findUnique({
where: { id: assetId },
})
if (!asset) {
return {
success: false,
errors: { assetId: ["Asset not found"] },
}
}
if (asset.itemId !== itemId) {
await prisma.asset.update({
where: { id: assetId },
data: { itemId },
})
return {
success: false,
errors: { assetId: ["Asset does not belong to item"] },
}
}
}
await prisma.assignment.update({
where: { id },
data: {
...validatedFields.data,
itemId,
assetId,
quantity,
returnDate: new Date(),
},
})
revalidatePath("/assignments")
return {
success: true,
message: "Assignment updated successfully",
}
} catch (error) {
console.error("Database error:", error)
return {
success: false,
errors: { error: ["Error creating assignment"] },
}
}
}
export async function returnAssignment(formData: ReturnAssignmentFormType) {
const { id } = formData
const assignment = await AssignmentService.findById(id)
if (!assignment) {
return {
success: false,
errors: { id: ["Assignment not found"] },
}
}
if (assignment.returnDate) {
return {
success: false,
errors: { id: ["Assignment already returned"] },
}
}
if (assignment.itemId && assignment.quantity) {
await ItemService.update(assignment.itemId, {
stock: { increment: assignment.quantity || 1 },
})
}
if (assignment.assetId) {
await AssetService.update(assignment.assetId, {
status: "AVAILABLE",
})
}
await AssignmentService.delete(id)
await MovementService.create({
type: "RETURN",
quantity: assignment.quantity || 1,
itemId: assignment.itemId || undefined,
assetId: assignment.assetId || undefined,
assignmentId: id,
})
revalidatePath("/assignments")
return {
success: true,
message: "Assignment returned successfully",
}
}
+26
View File
@@ -0,0 +1,26 @@
"use server"
import { AuthError } from "next-auth"
import { signIn } from "@/lib/auth"
import { SignInFormType } from "@/lib/schemas/auth.schemas"
export async function signInAction(values: SignInFormType) {
const { username, password } = values
try {
await signIn("credentials", {
username,
password,
redirect: false,
})
return {
success: true,
}
} catch (error) {
if (error instanceof AuthError) {
return { error: error.cause?.err?.message }
}
return null
}
}
+152
View File
@@ -0,0 +1,152 @@
"use server"
import { revalidatePath } from "next/cache"
import {
CreateCategoryFormType,
createCategorySchema,
UpdateCategoryFormType,
updateCategorySchema,
} from "@/lib/schemas/category.schemas"
import { CategoryService } from "@/services/category.service"
export async function createCategoryAction(formData: CreateCategoryFormType) {
const validatedFields = createCategorySchema.safeParse(formData)
if (!validatedFields.success) {
return {
success: false,
errors: validatedFields.error.flatten().fieldErrors,
}
}
try {
const existingCategory = await CategoryService.findByName(
validatedFields.data.name,
)
if (existingCategory) {
return {
success: false,
errors: {
name: ["Category already exists"],
},
}
}
await CategoryService.create(validatedFields.data)
revalidatePath("/inventory/categories")
return {
success: true,
message: "Category created successfully",
}
} catch (error) {
console.error("Database error:", error)
return {
success: false,
message: "Failed to create category",
errors: {
name: ["Category already exists"],
},
}
}
}
export async function updateCategoryAction(formData: UpdateCategoryFormType) {
const validatedFields = updateCategorySchema.safeParse(formData)
if (!validatedFields.success) {
return {
success: false,
errors: validatedFields.error.flatten().fieldErrors,
}
}
const { id, name } = validatedFields.data
try {
const existingCategory = await CategoryService.findById(id)
if (!existingCategory) {
return {
success: false,
errors: { id: ["Category not found"] },
}
}
if (existingCategory.name === name) {
return {
success: false,
errors: { name: ["Category name is the same as the old one"] },
}
}
if (await CategoryService.findByName(name)) {
return {
success: false,
errors: { name: ["Category already exists"] },
}
}
await CategoryService.update(id, {
name,
})
revalidatePath("/inventory/categories")
return {
success: true,
message: "Category updated successfully",
}
} catch (error) {
console.error("Database error:", error)
return {
success: false,
message: "Failed to update category",
}
}
}
export async function deleteCategoryAction(formData: FormData) {
const { id } = Object.fromEntries(formData) as { id: string }
try {
const existingCategory = await CategoryService.findAllWithItemsCount()
const category = existingCategory.find((category) => category.id === id)
if (!category) {
return {
success: false,
errors: {
id: ["Category not found"],
},
}
}
if (category._count.items && category._count.items > 0) {
return {
success: false,
errors: {
id: ["Category has items"],
},
}
}
await CategoryService.delete(id)
revalidatePath("/inventory/categories")
return {
success: true,
message: "Category deleted successfully",
}
} catch (error) {
console.error("Database error:", error)
return {
success: false,
message: "Failed to delete category",
}
}
}
+335
View File
@@ -0,0 +1,335 @@
"use server"
import { revalidatePath } from "next/cache"
import Papa from "papaparse"
import { ImportFormType, importSchema } from "@/lib/schemas/import.schemas"
import { ImportItem } from "@/lib/types"
import { AssetService } from "@/services/asset.service"
import { AssignmentService } from "@/services/assignment.service"
import { CategoryService } from "@/services/category.service"
import { ItemService } from "@/services/item.service"
import { MovementService } from "@/services/movement.service"
import { RecipientService } from "@/services/recipient.service"
export async function importItems(formData: ImportFormType) {
const validatedFields = importSchema.safeParse(formData)
if (!validatedFields.success) {
return {
errors: validatedFields.error.flatten().fieldErrors,
}
}
const { file, categoryId } = validatedFields.data
if (!file) {
return {
errors: {
file: ["File is required"],
},
}
}
const fileStream = await file.text()
const parsedFile = Papa.parse<Record<string, string | undefined>>(
fileStream,
{
header: true,
skipEmptyLines: true,
dynamicTyping: false,
},
)
const { data: rows, meta, errors: papaErrors } = parsedFile
if (papaErrors.length > 0) {
return {
errors: {
file: papaErrors.map((err) => err.message).flat(),
},
}
}
if (!rows || rows.length === 0) {
return {
errors: {
file: ["File is empty"],
},
}
}
const fileHeaders = meta?.fields || []
if (fileHeaders.length === 0) {
return {
errors: {
file: ["File has no headers"],
},
}
}
if (!fileHeaders.includes("name")) {
return {
errors: {
file: ["Required header name is missing"],
},
}
}
if (categoryId) {
if (
fileHeaders.includes("category") ||
fileHeaders.includes("categoryId")
) {
return {
errors: {
file: [
"If you select a category in the form, you must not select a category or categoryId in the file",
],
},
}
} else {
if (
!fileHeaders.includes("category") &&
!fileHeaders.includes("categoryId")
) {
return {
errors: {
file: [
"If you not select a category in the form, you must select a category or categoryId in the file",
],
},
}
}
}
}
if (fileHeaders.includes("category") && fileHeaders.includes("categoryId")) {
return {
errors: {
file: [
"Only one of category or categoryId is allowed, you must select one of them",
],
},
}
}
if (
fileHeaders.includes("assigned") &&
!fileHeaders.includes("firstName") &&
!fileHeaders.includes("lastName")
) {
return {
errors: {
file: [
"If you select assigned, you must select firstName and lastName in the file",
],
},
}
}
const itemsToCreate: ImportItem[] = []
const importErrors: string[] = []
for (const [index, row] of rows.entries()) {
const {
name,
stock,
serialNumber,
categoryId,
category,
deliveryNote,
assigned,
username,
firstName,
lastName,
} = row
if (!name) {
importErrors.push(`Row ${index + 2}: Name is required`)
}
if (!categoryId && !category) {
importErrors.push(`Row ${index + 2}: Category or categoryId is required`)
}
if (stock && isNaN(Number(stock))) {
importErrors.push(`Row ${index + 2}: Stock must be a number`)
}
if (serialNumber && typeof serialNumber !== "string") {
importErrors.push(`Row ${index + 2}: Serial number must be a string`)
}
if (deliveryNote && typeof deliveryNote !== "string") {
importErrors.push(`Row ${index + 2}: Delivery note must be a string`)
}
if (username && typeof username !== "string") {
importErrors.push(`Row ${index + 2}: Username must be a string`)
}
if (firstName && typeof firstName !== "string") {
importErrors.push(`Row ${index + 2}: First name must be a string`)
}
if (lastName && typeof lastName !== "string") {
importErrors.push(`Row ${index + 2}: Last name must be a string`)
}
if (assigned === "true" && !firstName && !lastName) {
importErrors.push(
`Row ${index + 2}: If assigned is true, firstName and lastName are required`,
)
}
}
if (importErrors.length > 0) {
return {
errors: {
file: importErrors,
},
}
}
for (const row of rows) {
itemsToCreate.push({
name: row.name?.trim() || "",
stock: row.stock ? Number(row.stock) : row.serialNumber ? 1 : 0,
serialNumber: row.serialNumber?.trim() || "",
categoryId: categoryId ? categoryId : row.categoryId?.trim() || "",
category: row.category?.trim() || "",
deliveryNote: row.deliveryNote?.trim() || "",
assigned: row.assigned?.trim() === "true" ? true : false,
username: row.username?.trim() || "",
firstName: row.firstName?.trim() || "",
lastName: row.lastName?.trim() || "",
})
}
for (const item of itemsToCreate) {
const {
name,
stock,
serialNumber,
categoryId,
category,
deliveryNote,
assigned,
username,
firstName,
lastName,
} = item
// Reset variables at the beginning of each iteration
let newItem
let newAsset
let newCategory
let newRecipient
let newAssignment
const existingCategory = categoryId
? await CategoryService.findById(categoryId)
: await CategoryService.findByName(category || "")
if (!existingCategory && category) {
newCategory = await CategoryService.create({ name: category })
} else {
newCategory = existingCategory
}
const existingItem = await ItemService.findByName(name)
if (!existingItem) {
newItem = await ItemService.create({
name,
stock: assigned ? 0 : stock || 0,
category: {
connect: { id: categoryId ? categoryId : newCategory?.id || "" },
},
})
} else {
newItem = existingItem
await ItemService.update(existingItem.id, {
name: existingItem.name,
stock: !assigned
? (existingItem?.stock || 0) + (stock || 1)
: existingItem?.stock || 0,
})
}
if (serialNumber) {
const existingAsset = await AssetService.findBySerialNumber(serialNumber)
if (!existingAsset) {
newAsset = await AssetService.create({
item: {
connect: { id: newItem.id },
},
serialNumber,
status: assigned ? "ASSIGNED" : "AVAILABLE",
deliveryNote: deliveryNote || "",
})
} else {
// Skip asset if already exists
continue
}
}
if (assigned && firstName && lastName) {
const finalUsername =
username || `${firstName.toLowerCase()[0]}${lastName.toLowerCase()}`
const existingRecipient =
await RecipientService.findByUsername(finalUsername)
if (!existingRecipient) {
newRecipient = await RecipientService.create({
username: finalUsername,
firstName,
lastName,
email: undefined,
phone: "",
department: "OTHER",
})
} else {
newRecipient = existingRecipient
}
newAssignment = await AssignmentService.create({
quantity: stock || 1,
notes: deliveryNote || "",
itemId: newItem?.id || "",
assetId: newAsset?.id || "",
recipientId: newRecipient?.id || "",
assignmentDate: new Date(),
})
}
const movementData: any = {
assetId: newAsset?.id || undefined,
quantity: stock || 1,
type: assigned ? "ASSIGNMENT" : "IN",
itemId: newItem?.id || undefined,
recipientId: newRecipient?.id || undefined,
}
if (newAssignment?.id) {
movementData.assignmentId = newAssignment.id
}
if (newRecipient?.id) {
movementData.recipientId = newRecipient.id
}
await MovementService.create(movementData)
}
revalidatePath("/inventory/items")
return {
success: true,
message: "Items imported successfully!",
}
}
+182
View File
@@ -0,0 +1,182 @@
"use server"
import { revalidatePath } from "next/cache"
import prisma from "@/lib/prisma"
import {
CreateItemFormType,
createItemSchema,
UpdateItemFormType,
updateItemSchema,
} from "@/lib/schemas/item.schemas"
import { getAuthenticatedUserId } from "@/services/auth.service"
import { ItemService } from "@/services/item.service"
export async function createItemAction(formData: CreateItemFormType) {
const validatedFields = createItemSchema.safeParse(formData)
if (!validatedFields.success) {
return {
errors: validatedFields.error.flatten().fieldErrors,
}
}
const { name, categoryId, stock } = validatedFields.data
if (stock < 0) {
return {
success: false,
errors: {
stock: ["Stock cannot be negative"],
},
}
}
try {
const existingItem = await ItemService.findByName(name)
if (existingItem) {
return {
success: false,
errors: {
name: ["An item with this name already exists"],
},
}
}
const item = await ItemService.create({
name,
category: { connect: { id: categoryId } },
stock: stock || 0,
})
if (stock > 0) {
await prisma.movement.create({
data: {
type: "IN",
itemId: item.id,
userId: await getAuthenticatedUserId(),
quantity: stock,
},
})
}
revalidatePath("/inventory/items")
return {
success: true,
message: "Item created successfully!",
}
} catch (error) {
console.error("Database error:", error)
return {
error: "Error creating item",
}
}
}
export async function updateItemAction(formData: UpdateItemFormType) {
const validatedFields = updateItemSchema.safeParse(formData)
if (!validatedFields.success) {
return {
errors: validatedFields.error.flatten().fieldErrors,
}
}
const { id, stock, name, categoryId } = validatedFields.data
try {
const existingItem = await ItemService.findByIdWithAssetCount(id)
if (!existingItem) {
return {
success: false,
errors: {
id: ["Item not found"],
},
}
}
const existingItemByName = await ItemService.findByName(name)
if (existingItemByName && existingItemByName.id !== id) {
return {
success: false,
errors: { name: ["An item with this name already exists"] },
}
}
await ItemService.update(id, {
stock: stock || existingItem.stock,
name: name || existingItem.name,
category: { connect: { id: categoryId } },
})
const quantity = stock - existingItem.stock
if (stock && stock > existingItem.stock) {
await prisma.movement.create({
data: {
type: "IN",
itemId: id,
quantity,
userId: await getAuthenticatedUserId(),
},
})
}
return {
success: true,
message: "Item updated successfully!",
}
} catch (error) {
console.error("Database error:", error)
return {
error: "Failed to update item",
}
}
}
export async function deleteItemAction(formData: FormData) {
const { id } = Object.fromEntries(formData) as { id: string }
try {
const existingItem = await ItemService.findByIdWithAssetCount(id)
if (!existingItem) {
return {
success: false,
errors: { id: ["Item not found"] },
}
}
if (existingItem._count.assets > 0) {
return {
success: false,
errors: { id: ["Item has assets, you cannot delete it"] },
}
}
if (existingItem.stock > 0) {
return {
success: false,
errors: { id: ["Item has stock, you cannot delete it"] },
}
}
await ItemService.delete(id)
revalidatePath("/inventory/items")
return {
success: true,
message: "Item deleted successfully!",
}
} catch (error) {
console.error("Database error:", error)
return {
error: "Failed to delete item",
}
}
}
+15
View File
@@ -0,0 +1,15 @@
import {
CreateMovementFormType,
createMovementSchema,
} from "../schemas/movement.schemas"
export async function createMovementAction(data: CreateMovementFormType) {
const validatedFields = createMovementSchema.safeParse(data)
if (!validatedFields.success) {
return {
success: false,
errors: validatedFields.error.flatten().fieldErrors,
}
}
}
+138
View File
@@ -0,0 +1,138 @@
"use server"
import { revalidatePath } from "next/cache"
import prisma from "@/lib/prisma"
import {
CreateRecipientFormType,
createRecipientSchema,
UpdateRecipientFormType,
updateRecipientSchema,
} from "@/lib/schemas/recipients.schemas"
import { RecipientService } from "@/services/recipient.service"
export async function createNewRecipient(formData: CreateRecipientFormType) {
const validatedFields = createRecipientSchema.safeParse(formData)
if (!validatedFields.success) {
return {
success: false,
errors: validatedFields.error.flatten().fieldErrors,
}
}
const { username, firstName, lastName, department, email, phone } =
validatedFields.data
try {
const existingRecipientUsername =
await RecipientService.findByUsername(username)
if (existingRecipientUsername) {
return {
success: false,
errors: {
username: ["Username already exists"],
},
}
}
const existingRecipientEmail = await RecipientService.findByEmail(
email || "",
)
if (existingRecipientEmail) {
return {
success: false,
errors: {
email: ["Email already exists"],
},
}
}
await RecipientService.create({
username: username || (firstName[0] + lastName).toLowerCase(),
firstName,
lastName,
department: department,
email: email || null,
phone: phone || null,
})
revalidatePath("/recipients")
return {
success: true,
message: "Recipient created successfully",
}
} catch (error) {
console.error("Database error:", error)
return {
message: "Failed to create recipient",
}
}
}
export async function updateRecipient(formData: UpdateRecipientFormType) {
const validatedFields = updateRecipientSchema.safeParse(formData)
if (!validatedFields.success) {
return {
success: false,
errors: validatedFields.error.flatten().fieldErrors,
}
}
const { id, username, firstName, lastName, department, email, phone } =
validatedFields.data
try {
const existingRecipient = await RecipientService.findByUsername(username)
if (existingRecipient && existingRecipient.id !== id) {
return {
success: false,
errors: {
username: ["Username already exists"],
},
}
}
const existingRecipientEmail = await RecipientService.findByEmail(
email || "",
)
if (existingRecipientEmail && existingRecipientEmail.id !== id) {
return {
success: false,
errors: {
email: ["Email already exists"],
},
}
}
await prisma.recipient.update({
where: { id },
data: {
username,
firstName,
lastName,
department: department,
email: email || null,
phone: phone || null,
},
})
revalidatePath("/recipients")
return {
success: true,
message: "Recipient updated successfully",
}
} catch (error) {
console.error("Database error:", error)
return {
message: "Failed to update recipient",
}
}
}
+90
View File
@@ -0,0 +1,90 @@
import NextAuth, { type DefaultSession } from "next-auth"
import Credentials from "next-auth/providers/credentials"
import { ZodError } from "zod"
import { UserRole } from "@/generated/prisma/client"
import { SIGN_IN_URL, TOKEN_EXPIRATION_SECONDS } from "@/lib/constants"
import { signInSchema } from "@/lib/schemas/auth.schemas"
import { verifyPassword } from "@/lib/security"
import { getUserByUsername } from "@/services/user.service"
declare module "next-auth" {
// eslint-disable-next-line no-unused-vars
interface Session {
user: {
id: string
role: UserRole
} & DefaultSession["user"]
}
// eslint-disable-next-line no-unused-vars
interface User {
role: UserRole
}
}
export const { handlers, signIn, signOut, auth } = NextAuth({
session: {
strategy: "jwt",
maxAge: TOKEN_EXPIRATION_SECONDS,
},
providers: [
Credentials({
credentials: {
username: {},
password: {},
},
authorize: async (credentials) => {
try {
const { data, success } = signInSchema.safeParse(credentials)
if (!success) throw new Error("Invalid username or password")
const user = await getUserByUsername(data.username)
if (!user) {
throw new Error("Invalid username or password")
}
if (!(await verifyPassword(data.password, user.password)))
throw new Error("Invalid username or password")
return {
...user,
role: user.role,
}
} catch (error) {
if (error instanceof ZodError) {
return null
}
throw new Error("Invalid username or password")
}
},
}),
],
pages: {
signIn: SIGN_IN_URL,
},
callbacks: {
async session({ session, token }) {
if (!token) return session
session.user.id = token.sub as string
session.user.name = token.name as string
session.user.email = token.email as string
session.user.role = token.role as UserRole
return session
},
async jwt({ token, user }) {
if (user) {
token.sub = user.id
token.name = user.name
token.email = user.email
token.role = user.role
}
return token
},
authorized: async ({ auth }) => {
return !!auth
},
},
})
+8
View File
@@ -0,0 +1,8 @@
export const SITE_URL =
process.env.NODE_ENV === "development"
? "http://localhost:3000"
: process.env.DOMAIN
export const SIGN_IN_URL = "/login"
export const TOKEN_EXPIRATION_SECONDS = 60 * 60 * 2 // 2 hour
+45
View File
@@ -0,0 +1,45 @@
import { Prisma } from "@/generated/prisma/client"
import { PaginatedResult } from "@/lib/types"
//eslint-disable-next-line
type PaginationArgs<T> = {
model: any // prisma.<model>
where?: Prisma.Enumerable<any> // filtros
page?: number
pageSize?: number
orderBy?: any
include?: Prisma.Enumerable<any>
select?: Prisma.Enumerable<any>
}
export async function paginate<T>({
model,
where = {},
page = 1,
pageSize = 10,
orderBy = { createdAt: "desc" },
include,
select,
}: PaginationArgs<T>): Promise<PaginatedResult<T>> {
const skip = (page - 1) * pageSize
const [data, totalItems] = await Promise.all([
model.findMany({
where,
skip,
take: pageSize,
orderBy,
include,
select,
}),
model.count({ where }),
])
return {
data,
totalItems,
totalPages: Math.ceil(totalItems / pageSize),
currentPage: page,
pageSize,
}
}
+11
View File
@@ -0,0 +1,11 @@
import { PrismaClient } from "@/generated/prisma/client"
const globalForPrisma = global as unknown as {
prisma: PrismaClient
}
const prisma = globalForPrisma.prisma || new PrismaClient()
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma
export default prisma
+23
View File
@@ -0,0 +1,23 @@
import { z } from "zod"
export const assetSchema = z.object({
id: z.string().optional(),
itemId: z.string().min(1, { message: "Item is required" }),
serialNumber: z.string().min(1, { message: "Serial number is required" }),
deliveryNote: z.string().optional(),
notes: z.string().optional(),
recipientId: z.string().optional(),
})
export const createAssetSchema = assetSchema.extend({
status: z.enum(["AVAILABLE", "ASSIGNED"]),
})
export type CreateAssetFormType = z.infer<typeof createAssetSchema>
export const updateAssetSchema = assetSchema.extend({
id: z.string().min(1, { message: "ID is required" }),
status: z.enum(["AVAILABLE", "ASSIGNED", "RESERVED", "IN_REPAIR"]),
})
export type UpdateAssetFormType = z.infer<typeof updateAssetSchema>
+43
View File
@@ -0,0 +1,43 @@
import { z } from "zod"
export const assignmentSchema = z.object({
id: z.string().optional(),
quantity: z.coerce
.number()
.int()
.nonnegative()
.min(1, { message: "Quantity is required" }),
notes: z.string().optional(),
itemId: z.string().min(1, { message: "Item is required" }).optional(),
assetId: z.string().optional(),
recipientId: z.string().min(1, { message: "Recipient is required" }),
assignmentDate: z.date().optional(),
returnDate: z.date().optional(),
})
export const createAssignmentSchema = assignmentSchema.omit({
id: true,
returnDate: true,
})
export type CreateAssignmentFormType = z.infer<typeof createAssignmentSchema>
export const updateAssignmentSchema = assignmentSchema
.omit({
returnDate: true,
})
.superRefine((data, ctx) => {
if (data.itemId && !data.assetId) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Asset ID is required when item ID is provided",
path: ["assetId"],
})
}
})
export type UpdateAssignmentFormType = z.infer<typeof updateAssignmentSchema>
export const returnAssignmentSchema = z.object({
id: z.string().min(1, { message: "Assignment ID is required" }),
})
export type ReturnAssignmentFormType = z.infer<typeof returnAssignmentSchema>
+14
View File
@@ -0,0 +1,14 @@
import { z } from "zod"
export const signInSchema = z.object({
username: z
.string()
.min(1, { message: "Invalid username" })
.nonempty("Username is required"),
password: z
.string()
.min(3, { message: "Password is too short" })
.nonempty("Password is required"),
})
export type SignInFormType = z.infer<typeof signInSchema>
+18
View File
@@ -0,0 +1,18 @@
import { z } from "zod"
export const createCategorySchema = z.object({
name: z
.string()
.min(3, {
message: "Name is required and must be at least 3 characters long",
})
.nonempty("Name is required and must be at least 3 characters long"),
})
export type CreateCategoryFormType = z.infer<typeof createCategorySchema>
export const updateCategorySchema = createCategorySchema.extend({
id: z.string().nonempty("ID is required"),
})
export type UpdateCategoryFormType = z.infer<typeof updateCategorySchema>
+10
View File
@@ -0,0 +1,10 @@
import { z } from "zod"
export const importSchema = z.object({
file: z.instanceof(File).refine((file) => file.type === "text/csv", {
message: "File must be a CSV",
}),
categoryId: z.string().optional(),
})
export type ImportFormType = z.infer<typeof importSchema>
+25
View File
@@ -0,0 +1,25 @@
import { z } from "zod"
export const createItemSchema = z.object({
name: z.string().min(1, { message: "Name is required" }),
categoryId: z.string().min(1, { message: "Category is required" }),
stock: z.coerce
.number()
.int()
.nonnegative()
.min(0, { message: "Stock is required" }),
})
export type CreateItemFormType = z.infer<typeof createItemSchema>
export const updateItemSchema = createItemSchema.extend({
id: z.string().min(1, { message: "Item is required" }),
})
export type UpdateItemFormType = z.infer<typeof updateItemSchema>
export const getItemByIdSchema = z.object({
id: z.string().min(1, { message: "Item is required" }),
})
export type GetItemByIdFormType = z.infer<typeof getItemByIdSchema>
+23
View File
@@ -0,0 +1,23 @@
import { z } from "zod"
export const movementSchema = z.object({
type: z.enum(["IN", "OUT", "ASSIGNMENT", "RETURN", "ADJUSTMENT"]),
quantity: z.coerce
.number()
.int()
.nonnegative()
.min(1, { message: "Quantity is required" }),
details: z.string().optional(),
notes: z.string().optional(),
itemId: z.string().optional(),
assetId: z.string().optional(),
userId: z.string(),
assignmentId: z.string().optional(),
recipientId: z.string().optional(),
})
export const createMovementSchema = movementSchema.omit({
userId: true,
})
export type CreateMovementFormType = z.infer<typeof createMovementSchema>
+48
View File
@@ -0,0 +1,48 @@
import { z } from "zod"
export const recipientSchema = z.object({
id: z.string().optional(),
username: z
.string()
.min(1, { message: "Username is required" })
.nonempty("Username is required"),
firstName: z.string().min(1, { message: "First name is required" }),
lastName: z.string().min(1, { message: "Last name is required" }),
department: z.enum(
[
"IT",
"ENGINEERING",
"TRAFFIC",
"DRIVER",
"LOGISTICS",
"ADMINISTRATION",
"SALES",
"OTHER",
],
{
message: "Department is required",
},
),
email: z.string().optional().nullable(),
phone: z.string().optional().nullable(),
})
export const createRecipientSchema = recipientSchema.superRefine(
(data, ctx) => {
if (data.email && !z.string().email().safeParse(data.email).success) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Email format is invalid",
path: ["email"],
})
}
},
)
export type CreateRecipientFormType = z.infer<typeof createRecipientSchema>
export const updateRecipientSchema = recipientSchema.extend({
id: z.string().nonempty("ID is required"),
})
export type UpdateRecipientFormType = z.infer<typeof updateRecipientSchema>
+25
View File
@@ -0,0 +1,25 @@
import * as bcrypt from "bcryptjs"
export const runtime = "nodejs"
const saltRounds: number = 10
export async function verifyPassword(
plainPassword: string,
hashedPassword: string,
): Promise<boolean> {
const isMatch = await bcrypt.compare(plainPassword, hashedPassword)
if (!isMatch) {
return false
}
return isMatch
}
export async function getPasswordHash(password: string): Promise<string> {
if (!password) {
throw new Error("Password is required")
}
const salt = await bcrypt.genSalt(saltRounds)
const hash = await bcrypt.hash(password, salt)
return hash
}
+34
View File
@@ -0,0 +1,34 @@
import {
Asset as PrismaAsset,
Assignment,
ItemStatus as PrismaItemStatus,
} from "@/generated/prisma/client"
export type Asset = PrismaAsset
export type ItemStatus = PrismaItemStatus
export type NewAssetStatus = "AVAILABLE" | "ASSIGNED"
export type UpdateAssetStatus = NewAssetStatus | "RESERVED" | "IN_REPAIR"
export type AssetSummary = Pick<Asset, "id" | "serialNumber" | "status">
export type AssetWithAssignment = Asset & {
assignment: Assignment | null
}
export type AssetWithItemAndCategory = {
id: string
serialNumber: string
deliveryNote?: string | null
status: ItemStatus
item: {
id: string
name: string
category: {
id: string
name: string
}
} | null
}
+21
View File
@@ -0,0 +1,21 @@
import { Assignment as PrismaAssignment } from "@/generated/prisma/client"
import { Asset } from "./asset"
import { Item } from "./item"
import { Recipient } from "./recipient"
export type Assignment = PrismaAssignment
export type AssignmentSummary = Pick<Assignment, "id" | "quantity">
export type AssignmentWithRecipientItemAsset = Assignment & {
returnDate: Date | null
recipient: Recipient | null
item: Item | null
asset: Asset | null
}
export type AssignmentWithItemAndAsset = AssignmentSummary & {
item: Item
asset: Asset
}
+22
View File
@@ -0,0 +1,22 @@
import { Category as PrismaCategory } from "@/generated/prisma/client"
export type Category = PrismaCategory
export type CategorySummary = Pick<Category, "id" | "name">
export type CategoryWithItemsCount = CategorySummary & {
_count: {
items: number
}
}
export interface CreateCategoryInput {
name: string
description?: string | null
}
export interface UpdateCategoryInput {
name?: string
description?: string | null
isActive?: boolean
}
+12
View File
@@ -0,0 +1,12 @@
export interface ImportItem {
name: string
stock?: number
serialNumber?: string
categoryId?: string
category?: string
deliveryNote?: string
assigned?: boolean
username?: string
firstName?: string
lastName?: string
}
+9
View File
@@ -0,0 +1,9 @@
export * from "./asset"
export * from "./assignment"
export * from "./category"
export * from "./import"
export * from "./item"
export * from "./movement"
export * from "./paginate"
export * from "./recipient"
export * from "./user"
+21
View File
@@ -0,0 +1,21 @@
import { Category, Item as PrismaItem } from "@/generated/prisma/client"
export type Item = PrismaItem
export type ItemSummary = Pick<Item, "id" | "name" | "stock"> & {
category: Pick<Category, "id" | "name">
}
export type ItemWithoutStock = Pick<Item, "id" | "name">
export type ItemWithAssetCount = ItemSummary & {
_count: {
assets: number
}
}
export type ItemWithAssetAndMovementCount = ItemWithAssetCount & {
_count: {
movements: number
}
}
+24
View File
@@ -0,0 +1,24 @@
import { Movement as PrismaMovement } from "@/generated/prisma/client"
import { Asset } from "./asset"
import { Item } from "./item"
import { Recipient } from "./recipient"
export type Movement = PrismaMovement
export type MovementSummary = Pick<Movement, "id" | "type" | "quantity">
export type MovementWithItem = Movement & {
item: Item | null
}
export type MovementWithItemAndAsset = Movement & {
item: Item | null
asset: Asset | null
}
export type MovementWithRecipientItemAsset = Movement & {
recipient: Recipient | null
item: Item | null
asset: Asset | null
}
+7
View File
@@ -0,0 +1,7 @@
export type PaginatedResult<T> = {
data: T[]
totalItems: number
totalPages: number
currentPage: number
pageSize: number
}
+3
View File
@@ -0,0 +1,3 @@
import { Recipient as PrismaRecipient } from "@/generated/prisma/client"
export type Recipient = PrismaRecipient
+3
View File
@@ -0,0 +1,3 @@
import { User as PrismaUser } from "@/generated/prisma/client"
export type User = PrismaUser
+14
View File
@@ -0,0 +1,14 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function formatDate(date: Date) {
return new Intl.DateTimeFormat("es-ES", {
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(date)
}