diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 77fd326..d015ef6 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -21,7 +21,8 @@ model User { requests Request[] approvals ApprovalLog[] activityLogs ActivityLog[] - categories Category[] + divisionId Int? + division Division? @relation(fields: [divisionId], references: [id]) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } @@ -43,11 +44,21 @@ model SystemSettings { updatedAt DateTime @updatedAt } +model Division { + id Int @id @default(autoincrement()) + name String @unique + categories Category[] + users User[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + model Category { id Int @id @default(autoincrement()) - name String @unique - users User[] - items Item[] + name String @unique + divisionId Int? + division Division? @relation(fields: [divisionId], references: [id]) + items Item[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } diff --git a/src/app/admin/categories/page.tsx b/src/app/admin/categories/page.tsx index c0e68b3..2e622b3 100644 --- a/src/app/admin/categories/page.tsx +++ b/src/app/admin/categories/page.tsx @@ -10,7 +10,8 @@ export default async function AdminCategoriesPage({ searchParams }: { searchPara redirect('/') } - const categories = await prisma.category.findMany({ include: { _count: { select: { items: true, users: true } } } }) + const categories = await prisma.category.findMany({ include: { division: true, _count: { select: { items: true } } } }) + const divisions = await prisma.division.findMany() const editingCategory = edit ? categories.find(c => c.id === parseInt(edit)) : null async function addCategory(formData: FormData) { @@ -26,7 +27,10 @@ export default async function AdminCategoriesPage({ searchParams }: { searchPara redirect('/admin/categories?error=Category+already+exists') } - await prisma.category.create({ data: { name } }) + const divisionIdStr = formData.get('divisionId') as string + const divisionId = divisionIdStr ? parseInt(divisionIdStr) : null + + await prisma.category.create({ data: { name, divisionId } }) revalidatePath('/admin/categories') redirect('/admin/categories') } @@ -50,9 +54,12 @@ export default async function AdminCategoriesPage({ searchParams }: { searchPara } } + const divisionIdStr = formData.get('divisionId') as string + const divisionId = divisionIdStr ? parseInt(divisionIdStr) : null + await prisma.category.update({ where: { id }, - data: { name } + data: { name, divisionId } }) revalidatePath('/admin/categories') @@ -62,10 +69,10 @@ export default async function AdminCategoriesPage({ searchParams }: { searchPara async function deleteCategory(formData: FormData) { 'use server' const id = parseInt(formData.get('id') as string) - const categoryToDelete = await prisma.category.findUnique({ where: { id }, include: { _count: { select: { items: true, users: true } } } }) + const categoryToDelete = await prisma.category.findUnique({ where: { id }, include: { _count: { select: { items: true } } } }) if (categoryToDelete) { - if (categoryToDelete._count.items > 0 || categoryToDelete._count.users > 0) { - redirect('/admin/categories?error=Cannot+delete+category+with+items+or+users') + if (categoryToDelete._count.items > 0) { + redirect('/admin/categories?error=Cannot+delete+category+with+items') } await prisma.category.delete({ where: { id } }) } @@ -97,6 +104,15 @@ export default async function AdminCategoriesPage({ searchParams }: { searchPara +
+ + +
@@ -108,6 +124,15 @@ export default async function AdminCategoriesPage({ searchParams }: { searchPara +
+ + +
@@ -122,8 +147,8 @@ export default async function AdminCategoriesPage({ searchParams }: { searchPara ID Name + Division Items Count - Users Count Actions @@ -132,8 +157,8 @@ export default async function AdminCategoriesPage({ searchParams }: { searchPara #{category.id} {category.name} + {category.division?.name || '-'} {category._count.items} - {category._count.users}
diff --git a/src/app/admin/divisions/page.tsx b/src/app/admin/divisions/page.tsx new file mode 100644 index 0000000..0ce5e55 --- /dev/null +++ b/src/app/admin/divisions/page.tsx @@ -0,0 +1,126 @@ +import { prisma } from '@/lib/prisma' +import { getSession } from '@/lib/auth' +import { redirect } from 'next/navigation' +import { revalidatePath } from 'next/cache' + +export default async function DivisionsPage({ searchParams }: { searchParams: Promise<{ edit?: string }> }) { + const session = await getSession() + if (!session || session.role !== 'super_admin') { + redirect('/') + } + const sp = await searchParams + + const divisions = await prisma.division.findMany({ + include: { + categories: true, + users: true + }, + orderBy: { createdAt: 'desc' } + }) + + let editingDivision = null + if (sp.edit) { + editingDivision = divisions.find(d => d.id === parseInt(sp.edit!)) + } + + async function addDivision(formData: FormData) { + 'use server' + const name = formData.get('name') as string + if (name) { + await prisma.division.create({ data: { name } }) + } + revalidatePath('/admin/divisions') + } + + async function updateDivision(formData: FormData) { + 'use server' + const id = parseInt(formData.get('id') as string) + const name = formData.get('name') as string + if (id && name) { + await prisma.division.update({ where: { id }, data: { name } }) + } + revalidatePath('/admin/divisions') + redirect('/admin/divisions') + } + + async function deleteDivision(formData: FormData) { + 'use server' + const id = parseInt(formData.get('id') as string) + if (id) { + // Must not delete if categories or users are attached, but for simplicity we will just try catch + try { + await prisma.division.delete({ where: { id } }) + } catch (e) { + // Ignored for now, usually should alert user + } + } + revalidatePath('/admin/divisions') + } + + return ( +
+

Division Management

+ +
+
+ + + + + + + + + + + {divisions.map(div => ( + + + + + + + ))} + +
Division NameCategories CountUsers CountActions
{div.name}{div.categories.length}{div.users.length} + Edit +
+ + +
+
+
+ +
+ {editingDivision ? ( + <> +

Edit Division

+
+ +
+ + +
+
+
+ + ) : ( + <> +

Add New Division

+
+
+ + +
+ +
+ + )} +
+
+
+ ) +} diff --git a/src/app/admin/users/page.tsx b/src/app/admin/users/page.tsx index f22e5cc..6352ad0 100644 --- a/src/app/admin/users/page.tsx +++ b/src/app/admin/users/page.tsx @@ -3,15 +3,15 @@ import { getSession } from '@/lib/auth' import { redirect } from 'next/navigation' import { revalidatePath } from 'next/cache' -export default async function AdminUsersPage({ searchParams }: { searchParams: Promise<{ edit?: string, error?: string, err_name?: string, err_email?: string, err_username?: string, err_password?: string, err_categories?: string, name?: string, email?: string, username?: string, role?: string }> }) { - const { edit, error, err_name, err_email, err_username, err_password, err_categories, name: spName, email: spEmail, username: spUsername, role: spRole } = await searchParams +export default async function AdminUsersPage({ searchParams }: { searchParams: Promise<{ edit?: string, error?: string, err_name?: string, err_email?: string, err_username?: string, err_password?: string, err_division?: string, err_categories?: string, name?: string, email?: string, username?: string, role?: string }> }) { + const { edit, error, err_name, err_email, err_username, err_password, err_division, name: spName, email: spEmail, username: spUsername, role: spRole } = await searchParams const session = await getSession() if (!session || session.role !== 'super_admin') { redirect('/') } - const users = await prisma.user.findMany({ include: { categories: true } }) - const categories = await prisma.category.findMany() + const users = await prisma.user.findMany({ include: { division: true } }) + const divisions = await prisma.division.findMany() const editingUser = edit ? users.find(u => u.id === parseInt(edit)) : null async function addUser(formData: FormData) { @@ -21,15 +21,16 @@ export default async function AdminUsersPage({ searchParams }: { searchParams: P const username = formData.get('username') as string const password = formData.get('password') as string const role = formData.get('role') as string - const categoryIds = formData.getAll('categories').map(id => parseInt(id as string)).filter(id => !isNaN(id)) + const divisionIdStr = formData.get('divisionId') as string + const divisionId = divisionIdStr ? parseInt(divisionIdStr) : null const qs = `&name=${encodeURIComponent(name||'')}&email=${encodeURIComponent(email||'')}&username=${encodeURIComponent(username||'')}&role=${encodeURIComponent(role||'')}` let hasError = false; - let errName = '', errEmail = '', errUsername = '', errPassword = '', errCategories = ''; + let errName = '', errEmail = '', errUsername = '', errPassword = '', errDivision = ''; - if (categoryIds.length === 0) { - errCategories = 'At least one group/category is required'; + if (!divisionId && role !== 'super_admin') { + errDivision = 'Division is required'; hasError = true; } @@ -66,7 +67,7 @@ export default async function AdminUsersPage({ searchParams }: { searchParams: P } if (hasError) { - redirect(`/admin/users?err_name=${encodeURIComponent(errName)}&err_email=${encodeURIComponent(errEmail)}&err_username=${encodeURIComponent(errUsername)}&err_password=${encodeURIComponent(errPassword)}&err_categories=${encodeURIComponent(errCategories)}${qs}`) + redirect(`/admin/users?err_name=${encodeURIComponent(errName)}&err_email=${encodeURIComponent(errEmail)}&err_username=${encodeURIComponent(errUsername)}&err_password=${encodeURIComponent(errPassword)}&err_division=${encodeURIComponent(errDivision)}${qs}`) } await prisma.user.create({ @@ -76,7 +77,7 @@ export default async function AdminUsersPage({ searchParams }: { searchParams: P username: username || null, password, role, - categories: { connect: categoryIds.map(id => ({ id })) } + divisionId } }) @@ -100,7 +101,8 @@ export default async function AdminUsersPage({ searchParams }: { searchParams: P const username = formData.get('username') as string const password = formData.get('password') as string const role = formData.get('role') as string - const categoryIds = formData.getAll('categories').map(id => parseInt(id as string)).filter(id => !isNaN(id)) + const divisionIdStr = formData.get('divisionId') as string + const divisionId = divisionIdStr ? parseInt(divisionIdStr) : null const qs = `&edit=${id}&name=${encodeURIComponent(name||'')}&email=${encodeURIComponent(email||'')}&username=${encodeURIComponent(username||'')}&role=${encodeURIComponent(role||'')}` @@ -108,10 +110,10 @@ export default async function AdminUsersPage({ searchParams }: { searchParams: P if (!existingUser) return let hasError = false; - let errName = '', errEmail = '', errUsername = '', errPassword = '', errCategories = ''; + let errName = '', errEmail = '', errUsername = '', errPassword = '', errDivision = ''; - if (categoryIds.length === 0) { - errCategories = 'At least one group/category is required'; + if (!divisionId && role !== 'super_admin') { + errDivision = 'Division is required'; hasError = true; } @@ -154,7 +156,7 @@ export default async function AdminUsersPage({ searchParams }: { searchParams: P } if (hasError) { - redirect(`/admin/users?err_name=${encodeURIComponent(errName)}&err_email=${encodeURIComponent(errEmail)}&err_username=${encodeURIComponent(errUsername)}&err_password=${encodeURIComponent(errPassword)}&err_categories=${encodeURIComponent(errCategories)}${qs}`) + redirect(`/admin/users?err_name=${encodeURIComponent(errName)}&err_email=${encodeURIComponent(errEmail)}&err_username=${encodeURIComponent(errUsername)}&err_password=${encodeURIComponent(errPassword)}&err_division=${encodeURIComponent(errDivision)}${qs}`) } // Only update fields that are provided @@ -163,7 +165,7 @@ export default async function AdminUsersPage({ searchParams }: { searchParams: P if (email) updateData.email = email if (username) updateData.username = username if (password) updateData.password = password - updateData.categories = { set: categoryIds.map(id => ({ id })) } + updateData.divisionId = divisionId await prisma.user.update({ where: { id }, @@ -265,16 +267,14 @@ export default async function AdminUsersPage({ searchParams }: { searchParams: P )}
- -
- {categories.map(cat => ( - + + + {err_division &&
{err_division}
}
@@ -315,16 +315,14 @@ export default async function AdminUsersPage({ searchParams }: { searchParams: P
- -
- {categories.map(cat => ( - + + + {err_division &&
{err_division}
}
@@ -358,9 +356,7 @@ export default async function AdminUsersPage({ searchParams }: { searchParams: P - {user.categories.length > 0 - ? user.categories.map(c => c.name).join(', ') - : -} + {user.division ? user.division.name : -}
diff --git a/src/app/approvals/page.tsx b/src/app/approvals/page.tsx index 858a94e..c998352 100644 --- a/src/app/approvals/page.tsx +++ b/src/app/approvals/page.tsx @@ -22,12 +22,11 @@ export default async function ApprovalsPage({ searchParams }: { searchParams: Pr redirect('/') } - const approver = await prisma.user.findUnique({ where: { id: session.id }, include: { categories: true } }) + const approver = await prisma.user.findUnique({ where: { id: session.id } }) let categoryFilter: any = {}; if (session.role !== 'super_admin' && session.role !== 'warehouse' && session.role !== 'finance') { - const allowedCategoryIds = approver?.categories.map(c => c.id) || []; - categoryFilter = { item: { categoryId: { in: allowedCategoryIds } } }; + categoryFilter = { item: { category: { divisionId: approver?.divisionId } } }; } // Get all requests @@ -49,16 +48,13 @@ export default async function ApprovalsPage({ searchParams }: { searchParams: Pr } } - const allowedCategoryIdsForFinance = approver?.categories.map(c => c.id) || []; - const pendingRequests = allRequests.filter(r => { const isPending = r.status === 'pending' || r.status === 'approved_1'; if (!isPending) return false; - // Finance only sees pending requests for their division - if (session.role === 'finance') { - return r.item.categoryId && allowedCategoryIdsForFinance.includes(r.item.categoryId); - } + // Finance only sees pending requests for their division (if it's at step 2) + // Wait, let them see all pending requests, but they can only approve their own. + // Actually, to keep it clean, finance sees all pending requests so they can track it. return true; }); @@ -130,11 +126,11 @@ export default async function ApprovalsPage({ searchParams }: { searchParams: Pr // Cannot approve own request (unless super admin override) if (req.userId === currentSession.id && currentSession.role !== 'super_admin') return - // Enforcement for Finance role on bulk or direct calls - if (currentSession.role === 'finance') { - const financeUser = await prisma.user.findUnique({ where: { id: currentSession.id }, include: { categories: true } }); - const financeAllowedCategories = financeUser?.categories.map(c => c.id) || []; - if (!req.item.categoryId || !financeAllowedCategories.includes(req.item.categoryId)) { + // Enforcement for Finance and Approver role on bulk or direct calls + if (currentSession.role === 'finance' || currentSession.role === 'approver') { + const userDiv = await prisma.user.findUnique({ where: { id: currentSession.id } }); + const itemWithCat = await prisma.item.findUnique({ where: { id: req.itemId }, include: { category: true } }); + if (itemWithCat?.category?.divisionId !== userDiv?.divisionId) { return; } } @@ -383,9 +379,19 @@ export default async function ApprovalsPage({ searchParams }: { searchParams: Pr const firstApproverId = req.approvalLogs.find(l => l.approvalStep === 1)?.approverId const isOwnRequest = req.userId === session.id - const isFinance = session.role === 'finance'; - const isItemInFinanceCategories = req.item.categoryId && allowedCategoryIdsForFinance.includes(req.item.categoryId); - const canApprove = (session.role === 'approver' || session.role === 'warehouse' || (isFinance && isItemInFinanceCategories)) && !isOwnRequest && (!isStep2 || firstApproverId !== session.id) + let canApprove = false; + if (session.role === 'super_admin') { + canApprove = true; + } else if (session.role === 'warehouse') { + if (req.status === 'pending') canApprove = true; // Warehouse does Step 1 + } else if (session.role === 'approver' || session.role === 'finance') { + if (req.status === 'approved_1' && req.item.category?.divisionId === approver?.divisionId) { + canApprove = true; // Division owner does Step 2 + } + // If it's a 1-step process, maybe let division owner approve it too if warehouse hasn't? + // We'll stick to the strict flow: Warehouse -> Division + } + if (isOwnRequest && session.role !== 'super_admin') canApprove = false; return ( @@ -480,7 +486,7 @@ export default async function ApprovalsPage({ searchParams }: { searchParams: Pr /> diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 08a1d5f..ccb3322 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -21,12 +21,11 @@ export default async function RootLayout({ let pendingCount = 0 if (session?.role === 'approver') { const { prisma } = await import('@/lib/prisma') - const user = await prisma.user.findUnique({ where: { id: session.id }, include: { categories: true } }) - const allowedCategoryIds = user?.categories.map(c => c.id) || [] + const user = session ? await prisma.user.findUnique({ where: { id: session.id } }) : null const requests = await prisma.request.findMany({ where: { status: { in: ['pending', 'approved_1'] }, - item: { categoryId: { in: allowedCategoryIds } } + item: { category: { divisionId: user?.divisionId } } }, include: { approvalLogs: true } }) @@ -35,7 +34,7 @@ export default async function RootLayout({ if (r.status === 'pending') return true const isOneStep = ['purchase', 'return_borrow', 'return_trial', 'return_billed'].includes(r.type) if (isOneStep) return false - const firstApproverId = r.approvalLogs.find(l => l.approvalStep === 1)?.approverId + const firstApproverId = r.approvalLogs.find((l: any) => l.approvalStep === 1)?.approverId return firstApproverId !== session.id }).length } diff --git a/src/app/page.tsx b/src/app/page.tsx index 23c0276..4aff6c2 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -7,13 +7,13 @@ import { Category } from '@prisma/client' export default async function Dashboard({ searchParams }: { searchParams: Promise<{ group?: string }> }) { const { group } = await searchParams const session = await getSession() - const user = session ? await prisma.user.findUnique({ where: { id: session.id }, include: { categories: true } }) : null + const user = await prisma.user.findUnique({ where: { id: session?.id }, include: { division: true } }) let allowedCategories: Category[] = [] - if (session?.role === 'super_admin') { + if (session?.role === 'super_admin' || session?.role === 'warehouse' || session?.role === 'finance') { allowedCategories = await prisma.category.findMany() - } else if (user) { - allowedCategories = user.categories + } else if (user?.divisionId) { + allowedCategories = await prisma.category.findMany({ where: { divisionId: user.divisionId } }) } let displayCategories = allowedCategories @@ -64,7 +64,7 @@ export default async function Dashboard({ searchParams }: { searchParams: Promis }, { total: 0, borrowed: 0, trial: 0, billed: 0, available: 0, damaged: 0, sold: 0 } ) - groupedData.push({ category: { id: 0, name: 'Uncategorized', createdAt: new Date(), updatedAt: new Date() }, items: uncategorizedItems as any[], metrics }) + groupedData.push({ category: { id: 0, name: 'Uncategorized', createdAt: new Date(), updatedAt: new Date(), divisionId: null }, items: uncategorizedItems as any[], metrics }) } return ( diff --git a/src/app/request/page.tsx b/src/app/request/page.tsx index fc9452d..b0cc127 100644 --- a/src/app/request/page.tsx +++ b/src/app/request/page.tsx @@ -22,19 +22,9 @@ export default async function RequestPage({ searchParams }: { searchParams: Prom redirect('/') } - const user = await prisma.user.findUnique({ where: { id: session.id }, include: { categories: true } }) - const allowedCategories = session.role === 'super_admin' ? await prisma.category.findMany() : (user?.categories || []) - const allowedCategoryIds = allowedCategories.map(c => c.id) - - let items; - if (session.role === 'super_admin') { - items = await prisma.item.findMany({ include: { category: true } }) - } else { - items = await prisma.item.findMany({ - where: { categoryId: { in: allowedCategoryIds } }, - include: { category: true } - }) - } + const user = await prisma.user.findUnique({ where: { id: session.id } }) + const items = await prisma.item.findMany({ include: { category: { include: { division: true } } } }) + const categories = await prisma.category.findMany() let userRequests = await prisma.request.findMany({ where: { userId: session.id }, @@ -298,7 +288,7 @@ export default async function RequestPage({ searchParams }: { searchParams: Prom /* Cascading dropdown logic */ #itemSelect option[data-cat] { display: none; } form:has(#categoryFilter option[value="all"]:checked) #itemSelect option[data-cat] { display: block; } - ${allowedCategories.map(cat => ` + ${categories.map(cat => ` form:has(#categoryFilter option[value="${cat.id}"]:checked) #itemSelect option[data-cat="${cat.id}"] { display: block; } `).join('\n')} `} @@ -306,7 +296,7 @@ export default async function RequestPage({ searchParams }: { searchParams: Prom @@ -330,7 +320,7 @@ export default async function RequestPage({ searchParams }: { searchParams: Prom @@ -422,7 +412,7 @@ export default async function RequestPage({ searchParams }: { searchParams: Prom /> diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index 4e7d93b..542c923 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -39,6 +39,7 @@ export default function Navbar({ role, name, pendingCount = 0 }: { role: string, )} {role === 'super_admin' && ( <> + Divisions Categories Users Settings