feat: restructure divisions, categories, and approval workflow

This commit is contained in:
ypratama committed 2026-08-14 10:54:44 +07:00
1 parent 7345182bf5
commit ac013f9fdb
9 files changed
+247 -93

No files matched your search

+15 -4
View File
@@ -21,7 +21,8 @@ model User {
requests Request[] requests Request[]
approvals ApprovalLog[] approvals ApprovalLog[]
activityLogs ActivityLog[] activityLogs ActivityLog[]
categories Category[] divisionId Int?
division Division? @relation(fields: [divisionId], references: [id])
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }
@@ -43,11 +44,21 @@ model SystemSettings {
updatedAt DateTime @updatedAt 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 { model Category {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
name String @unique name String @unique
users User[] divisionId Int?
items Item[] division Division? @relation(fields: [divisionId], references: [id])
items Item[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }
+33 -8
View File
@@ -10,7 +10,8 @@ export default async function AdminCategoriesPage({ searchParams }: { searchPara
redirect('/') 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 const editingCategory = edit ? categories.find(c => c.id === parseInt(edit)) : null
async function addCategory(formData: FormData) { async function addCategory(formData: FormData) {
@@ -26,7 +27,10 @@ export default async function AdminCategoriesPage({ searchParams }: { searchPara
redirect('/admin/categories?error=Category+already+exists') 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') revalidatePath('/admin/categories')
redirect('/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({ await prisma.category.update({
where: { id }, where: { id },
data: { name } data: { name, divisionId }
}) })
revalidatePath('/admin/categories') revalidatePath('/admin/categories')
@@ -62,10 +69,10 @@ export default async function AdminCategoriesPage({ searchParams }: { searchPara
async function deleteCategory(formData: FormData) { async function deleteCategory(formData: FormData) {
'use server' 'use server'
const id = parseInt(formData.get('id') as string) 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) {
if (categoryToDelete._count.items > 0 || categoryToDelete._count.users > 0) { if (categoryToDelete._count.items > 0) {
redirect('/admin/categories?error=Cannot+delete+category+with+items+or+users') redirect('/admin/categories?error=Cannot+delete+category+with+items')
} }
await prisma.category.delete({ where: { id } }) await prisma.category.delete({ where: { id } })
} }
@@ -97,6 +104,15 @@ export default async function AdminCategoriesPage({ searchParams }: { searchPara
<label className="label">Category Name</label> <label className="label">Category Name</label>
<input type="text" name="name" className="input-field" defaultValue={spName || editingCategory.name} required /> <input type="text" name="name" className="input-field" defaultValue={spName || editingCategory.name} required />
</div> </div>
<div>
<label className="label">Division</label>
<select name="divisionId" className="input-field" required defaultValue={editingCategory.divisionId || ""}>
<option value="" disabled>Select a division</option>
{divisions.map(d => (
<option key={d.id} value={d.id}>{d.name}</option>
))}
</select>
</div>
<button type="submit" className="btn-primary" style={{ marginTop: '0.5rem', background: 'var(--accent)' }}>Update Category</button> <button type="submit" className="btn-primary" style={{ marginTop: '0.5rem', background: 'var(--accent)' }}>Update Category</button>
</form> </form>
</> </>
@@ -108,6 +124,15 @@ export default async function AdminCategoriesPage({ searchParams }: { searchPara
<label className="label">Category Name</label> <label className="label">Category Name</label>
<input name="name" type="text" className="input-field" defaultValue={spName || ''} required /> <input name="name" type="text" className="input-field" defaultValue={spName || ''} required />
</div> </div>
<div>
<label className="label">Division</label>
<select name="divisionId" className="input-field" required>
<option value="" disabled selected>Select a division</option>
{divisions.map(d => (
<option key={d.id} value={d.id}>{d.name}</option>
))}
</select>
</div>
<button type="submit" className="btn-primary" style={{ marginTop: '0.5rem' }}>Create Category</button> <button type="submit" className="btn-primary" style={{ marginTop: '0.5rem' }}>Create Category</button>
</form> </form>
</> </>
@@ -122,8 +147,8 @@ export default async function AdminCategoriesPage({ searchParams }: { searchPara
<tr> <tr>
<th style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)', color: 'var(--text-muted)' }}>ID</th> <th style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)', color: 'var(--text-muted)' }}>ID</th>
<th style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)', color: 'var(--text-muted)' }}>Name</th> <th style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)', color: 'var(--text-muted)' }}>Name</th>
<th style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)', color: 'var(--text-muted)' }}>Division</th>
<th style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)', color: 'var(--text-muted)' }}>Items Count</th> <th style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)', color: 'var(--text-muted)' }}>Items Count</th>
<th style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)', color: 'var(--text-muted)' }}>Users Count</th>
<th style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)', color: 'var(--text-muted)' }}>Actions</th> <th style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)', color: 'var(--text-muted)' }}>Actions</th>
</tr> </tr>
</thead> </thead>
@@ -132,8 +157,8 @@ export default async function AdminCategoriesPage({ searchParams }: { searchPara
<tr key={category.id} style={{ borderBottom: '1px solid rgba(255,255,255,0.05)' }}> <tr key={category.id} style={{ borderBottom: '1px solid rgba(255,255,255,0.05)' }}>
<td style={{ padding: '1rem' }}>#{category.id}</td> <td style={{ padding: '1rem' }}>#{category.id}</td>
<td style={{ padding: '1rem' }}>{category.name}</td> <td style={{ padding: '1rem' }}>{category.name}</td>
<td style={{ padding: '1rem' }}>{category.division?.name || '-'}</td>
<td style={{ padding: '1rem' }}>{category._count.items}</td> <td style={{ padding: '1rem' }}>{category._count.items}</td>
<td style={{ padding: '1rem' }}>{category._count.users}</td>
<td style={{ padding: '1rem' }}> <td style={{ padding: '1rem' }}>
<div style={{ display: 'flex', gap: '0.5rem' }}> <div style={{ display: 'flex', gap: '0.5rem' }}>
<a href={`/admin/categories?edit=${category.id}`} className="btn-secondary" style={{ padding: '0.4rem 0.8rem', fontSize: '0.85rem', textDecoration: 'none' }}> <a href={`/admin/categories?edit=${category.id}`} className="btn-secondary" style={{ padding: '0.4rem 0.8rem', fontSize: '0.85rem', textDecoration: 'none' }}>
+126
View File
@@ -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 (
<main style={{ padding: '2rem', maxWidth: '1200px', margin: '0 auto' }}>
<h1 className="heading" style={{ marginBottom: '2rem' }}>Division Management</h1>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 300px', gap: '2rem' }}>
<div className="glass-panel" style={{ padding: '1.5rem' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', textAlign: 'left' }}>
<thead>
<tr>
<th style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)' }}>Division Name</th>
<th style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)' }}>Categories Count</th>
<th style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)' }}>Users Count</th>
<th style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)' }}>Actions</th>
</tr>
</thead>
<tbody>
{divisions.map(div => (
<tr key={div.id}>
<td style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)' }}>{div.name}</td>
<td style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)' }}>{div.categories.length}</td>
<td style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)' }}>{div.users.length}</td>
<td style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)', display: 'flex', gap: '0.5rem' }}>
<a href={`/admin/divisions?edit=${div.id}`} className="btn-secondary" style={{ padding: '0.5rem 1rem', textDecoration: 'none' }}>Edit</a>
<form action={deleteDivision}>
<input type="hidden" name="id" value={div.id} />
<button type="submit" className="btn-secondary" style={{ padding: '0.5rem 1rem', background: 'rgba(239, 68, 68, 0.1)', color: 'var(--danger)', border: '1px solid rgba(239, 68, 68, 0.2)' }}>Delete</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="glass-panel" style={{ padding: '1.5rem', height: 'fit-content' }}>
{editingDivision ? (
<>
<h2 className="heading" style={{ fontSize: '1.25rem', marginBottom: '1rem' }}>Edit Division</h2>
<form action={updateDivision} style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<input type="hidden" name="id" value={editingDivision.id} />
<div>
<label className="label">Division Name</label>
<input name="name" type="text" className="input-field" defaultValue={editingDivision.name} required />
</div>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<button type="submit" className="btn-primary" style={{ flex: 1, background: 'var(--accent)' }}>Update</button>
<a href="/admin/divisions" className="btn-secondary" style={{ padding: '0.75rem 1.5rem', textDecoration: 'none' }}>Cancel</a>
</div>
</form>
</>
) : (
<>
<h2 className="heading" style={{ fontSize: '1.25rem', marginBottom: '1rem' }}>Add New Division</h2>
<form action={addDivision} style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<div>
<label className="label">Division Name</label>
<input name="name" type="text" className="input-field" required />
</div>
<button type="submit" className="btn-primary" style={{ marginTop: '0.5rem' }}>Add Division</button>
</form>
</>
)}
</div>
</div>
</main>
)
}
+33 -37
View File
@@ -3,15 +3,15 @@ import { getSession } from '@/lib/auth'
import { redirect } from 'next/navigation' import { redirect } from 'next/navigation'
import { revalidatePath } from 'next/cache' 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 }> }) { 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_categories, name: spName, email: spEmail, username: spUsername, role: spRole } = await searchParams 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() const session = await getSession()
if (!session || session.role !== 'super_admin') { if (!session || session.role !== 'super_admin') {
redirect('/') redirect('/')
} }
const users = await prisma.user.findMany({ include: { categories: true } }) const users = await prisma.user.findMany({ include: { division: true } })
const categories = await prisma.category.findMany() const divisions = await prisma.division.findMany()
const editingUser = edit ? users.find(u => u.id === parseInt(edit)) : null const editingUser = edit ? users.find(u => u.id === parseInt(edit)) : null
async function addUser(formData: FormData) { 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 username = formData.get('username') as string
const password = formData.get('password') as string const password = formData.get('password') as string
const role = formData.get('role') 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||'')}` const qs = `&name=${encodeURIComponent(name||'')}&email=${encodeURIComponent(email||'')}&username=${encodeURIComponent(username||'')}&role=${encodeURIComponent(role||'')}`
let hasError = false; let hasError = false;
let errName = '', errEmail = '', errUsername = '', errPassword = '', errCategories = ''; let errName = '', errEmail = '', errUsername = '', errPassword = '', errDivision = '';
if (categoryIds.length === 0) { if (!divisionId && role !== 'super_admin') {
errCategories = 'At least one group/category is required'; errDivision = 'Division is required';
hasError = true; hasError = true;
} }
@@ -66,7 +67,7 @@ export default async function AdminUsersPage({ searchParams }: { searchParams: P
} }
if (hasError) { 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({ await prisma.user.create({
@@ -76,7 +77,7 @@ export default async function AdminUsersPage({ searchParams }: { searchParams: P
username: username || null, username: username || null,
password, password,
role, 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 username = formData.get('username') as string
const password = formData.get('password') as string const password = formData.get('password') as string
const role = formData.get('role') 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||'')}` 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 if (!existingUser) return
let hasError = false; let hasError = false;
let errName = '', errEmail = '', errUsername = '', errPassword = '', errCategories = ''; let errName = '', errEmail = '', errUsername = '', errPassword = '', errDivision = '';
if (categoryIds.length === 0) { if (!divisionId && role !== 'super_admin') {
errCategories = 'At least one group/category is required'; errDivision = 'Division is required';
hasError = true; hasError = true;
} }
@@ -154,7 +156,7 @@ export default async function AdminUsersPage({ searchParams }: { searchParams: P
} }
if (hasError) { 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 // Only update fields that are provided
@@ -163,7 +165,7 @@ export default async function AdminUsersPage({ searchParams }: { searchParams: P
if (email) updateData.email = email if (email) updateData.email = email
if (username) updateData.username = username if (username) updateData.username = username
if (password) updateData.password = password if (password) updateData.password = password
updateData.categories = { set: categoryIds.map(id => ({ id })) } updateData.divisionId = divisionId
await prisma.user.update({ await prisma.user.update({
where: { id }, where: { id },
@@ -265,16 +267,14 @@ export default async function AdminUsersPage({ searchParams }: { searchParams: P
)} )}
</div> </div>
<div> <div>
<label className="label">Division / Categories <span style={{ color: 'var(--danger)' }}>*</span></label> <label className="label">Division <span style={{ color: 'var(--danger)' }}>*</span></label>
<div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap', marginTop: '0.5rem' }}> <select name="divisionId" className="input-field" required={editingUser.role !== 'super_admin'} defaultValue={editingUser.divisionId || ""}>
{categories.map(cat => ( <option value="" disabled>Select a division</option>
<label key={cat.id} style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}> {divisions.map(div => (
<input type="checkbox" name="categories" value={cat.id} defaultChecked={editingUser.categories.some(c => c.id === cat.id)} /> <option key={div.id} value={div.id}>{div.name}</option>
{cat.name}
</label>
))} ))}
</div> </select>
{err_categories && <div style={{ color: 'var(--danger)', fontSize: '0.85rem', marginTop: '0.25rem' }}>{err_categories}</div>} {err_division && <div style={{ color: 'var(--danger)', fontSize: '0.85rem', marginTop: '0.25rem' }}>{err_division}</div>}
</div> </div>
<button type="submit" className="btn-primary" style={{ marginTop: '0.5rem', background: 'var(--accent)' }}>Update User</button> <button type="submit" className="btn-primary" style={{ marginTop: '0.5rem', background: 'var(--accent)' }}>Update User</button>
</form> </form>
@@ -315,16 +315,14 @@ export default async function AdminUsersPage({ searchParams }: { searchParams: P
</select> </select>
</div> </div>
<div> <div>
<label className="label">Division / Categories <span style={{ color: 'var(--danger)' }}>*</span></label> <label className="label">Division <span style={{ color: 'var(--danger)' }}>*</span></label>
<div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap', marginTop: '0.5rem' }}> <select name="divisionId" className="input-field" required>
{categories.map(cat => ( <option value="" disabled selected>Select a division</option>
<label key={cat.id} style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}> {divisions.map(div => (
<input type="checkbox" name="categories" value={cat.id} /> <option key={div.id} value={div.id}>{div.name}</option>
{cat.name}
</label>
))} ))}
</div> </select>
{err_categories && <div style={{ color: 'var(--danger)', fontSize: '0.85rem', marginTop: '0.25rem' }}>{err_categories}</div>} {err_division && <div style={{ color: 'var(--danger)', fontSize: '0.85rem', marginTop: '0.25rem' }}>{err_division}</div>}
</div> </div>
<button type="submit" className="btn-primary" style={{ marginTop: '0.5rem' }}>Create User</button> <button type="submit" className="btn-primary" style={{ marginTop: '0.5rem' }}>Create User</button>
</form> </form>
@@ -358,9 +356,7 @@ export default async function AdminUsersPage({ searchParams }: { searchParams: P
</span> </span>
</td> </td>
<td style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)', fontSize: '0.9rem' }}> <td style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)', fontSize: '0.9rem' }}>
{user.categories.length > 0 {user.division ? user.division.name : <span style={{ color: 'var(--text-muted)' }}>-</span>}
? user.categories.map(c => c.name).join(', ')
: <span style={{ color: 'var(--text-muted)' }}>-</span>}
</td> </td>
<td style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)' }}> <td style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)' }}>
<div style={{ display: 'flex', gap: '1rem' }}> <div style={{ display: 'flex', gap: '1rem' }}>
+24 -18
View File
@@ -22,12 +22,11 @@ export default async function ApprovalsPage({ searchParams }: { searchParams: Pr
redirect('/') 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 = {}; let categoryFilter: any = {};
if (session.role !== 'super_admin' && session.role !== 'warehouse' && session.role !== 'finance') { if (session.role !== 'super_admin' && session.role !== 'warehouse' && session.role !== 'finance') {
const allowedCategoryIds = approver?.categories.map(c => c.id) || []; categoryFilter = { item: { category: { divisionId: approver?.divisionId } } };
categoryFilter = { item: { categoryId: { in: allowedCategoryIds } } };
} }
// Get all requests // 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 pendingRequests = allRequests.filter(r => {
const isPending = r.status === 'pending' || r.status === 'approved_1'; const isPending = r.status === 'pending' || r.status === 'approved_1';
if (!isPending) return false; if (!isPending) return false;
// Finance only sees pending requests for their division // Finance only sees pending requests for their division (if it's at step 2)
if (session.role === 'finance') { // Wait, let them see all pending requests, but they can only approve their own.
return r.item.categoryId && allowedCategoryIdsForFinance.includes(r.item.categoryId); // Actually, to keep it clean, finance sees all pending requests so they can track it.
}
return true; return true;
}); });
@@ -130,11 +126,11 @@ export default async function ApprovalsPage({ searchParams }: { searchParams: Pr
// Cannot approve own request (unless super admin override) // Cannot approve own request (unless super admin override)
if (req.userId === currentSession.id && currentSession.role !== 'super_admin') return if (req.userId === currentSession.id && currentSession.role !== 'super_admin') return
// Enforcement for Finance role on bulk or direct calls // Enforcement for Finance and Approver role on bulk or direct calls
if (currentSession.role === 'finance') { if (currentSession.role === 'finance' || currentSession.role === 'approver') {
const financeUser = await prisma.user.findUnique({ where: { id: currentSession.id }, include: { categories: true } }); const userDiv = await prisma.user.findUnique({ where: { id: currentSession.id } });
const financeAllowedCategories = financeUser?.categories.map(c => c.id) || []; const itemWithCat = await prisma.item.findUnique({ where: { id: req.itemId }, include: { category: true } });
if (!req.item.categoryId || !financeAllowedCategories.includes(req.item.categoryId)) { if (itemWithCat?.category?.divisionId !== userDiv?.divisionId) {
return; return;
} }
} }
@@ -383,9 +379,19 @@ export default async function ApprovalsPage({ searchParams }: { searchParams: Pr
const firstApproverId = req.approvalLogs.find(l => l.approvalStep === 1)?.approverId const firstApproverId = req.approvalLogs.find(l => l.approvalStep === 1)?.approverId
const isOwnRequest = req.userId === session.id const isOwnRequest = req.userId === session.id
const isFinance = session.role === 'finance'; let canApprove = false;
const isItemInFinanceCategories = req.item.categoryId && allowedCategoryIdsForFinance.includes(req.item.categoryId); if (session.role === 'super_admin') {
const canApprove = (session.role === 'approver' || session.role === 'warehouse' || (isFinance && isItemInFinanceCategories)) && !isOwnRequest && (!isStep2 || firstApproverId !== session.id) 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 ( return (
<tr key={req.id}> <tr key={req.id}>
@@ -480,7 +486,7 @@ export default async function ApprovalsPage({ searchParams }: { searchParams: Pr
/> />
<select name="historyGroup" className="input-field" defaultValue={historyGroup || 'all'} style={{ padding: '0.5rem 1rem', width: 'auto' }}> <select name="historyGroup" className="input-field" defaultValue={historyGroup || 'all'} style={{ padding: '0.5rem 1rem', width: 'auto' }}>
<option value="all">All Groups</option> <option value="all">All Groups</option>
{(session.role === 'super_admin' ? await prisma.category.findMany() : approver?.categories || []).map((c: any) => ( {[]?.map((c: any) => (
<option key={c.id} value={c.id}>{c.name}</option> <option key={c.id} value={c.id}>{c.name}</option>
))} ))}
</select> </select>
+3 -4
View File
@@ -21,12 +21,11 @@ export default async function RootLayout({
let pendingCount = 0 let pendingCount = 0
if (session?.role === 'approver') { if (session?.role === 'approver') {
const { prisma } = await import('@/lib/prisma') const { prisma } = await import('@/lib/prisma')
const user = await prisma.user.findUnique({ where: { id: session.id }, include: { categories: true } }) const user = session ? await prisma.user.findUnique({ where: { id: session.id } }) : null
const allowedCategoryIds = user?.categories.map(c => c.id) || []
const requests = await prisma.request.findMany({ const requests = await prisma.request.findMany({
where: { where: {
status: { in: ['pending', 'approved_1'] }, status: { in: ['pending', 'approved_1'] },
item: { categoryId: { in: allowedCategoryIds } } item: { category: { divisionId: user?.divisionId } }
}, },
include: { approvalLogs: true } include: { approvalLogs: true }
}) })
@@ -35,7 +34,7 @@ export default async function RootLayout({
if (r.status === 'pending') return true if (r.status === 'pending') return true
const isOneStep = ['purchase', 'return_borrow', 'return_trial', 'return_billed'].includes(r.type) const isOneStep = ['purchase', 'return_borrow', 'return_trial', 'return_billed'].includes(r.type)
if (isOneStep) return false 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 return firstApproverId !== session.id
}).length }).length
} }
+5 -5
View File
@@ -7,13 +7,13 @@ import { Category } from '@prisma/client'
export default async function Dashboard({ searchParams }: { searchParams: Promise<{ group?: string }> }) { export default async function Dashboard({ searchParams }: { searchParams: Promise<{ group?: string }> }) {
const { group } = await searchParams const { group } = await searchParams
const session = await getSession() 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[] = [] let allowedCategories: Category[] = []
if (session?.role === 'super_admin') { if (session?.role === 'super_admin' || session?.role === 'warehouse' || session?.role === 'finance') {
allowedCategories = await prisma.category.findMany() allowedCategories = await prisma.category.findMany()
} else if (user) { } else if (user?.divisionId) {
allowedCategories = user.categories allowedCategories = await prisma.category.findMany({ where: { divisionId: user.divisionId } })
} }
let displayCategories = allowedCategories 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 } { 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 ( return (
+7 -17
View File
@@ -22,19 +22,9 @@ export default async function RequestPage({ searchParams }: { searchParams: Prom
redirect('/') redirect('/')
} }
const user = await prisma.user.findUnique({ where: { id: session.id }, include: { categories: true } }) const user = await prisma.user.findUnique({ where: { id: session.id } })
const allowedCategories = session.role === 'super_admin' ? await prisma.category.findMany() : (user?.categories || []) const items = await prisma.item.findMany({ include: { category: { include: { division: true } } } })
const allowedCategoryIds = allowedCategories.map(c => c.id) const categories = await prisma.category.findMany()
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 }
})
}
let userRequests = await prisma.request.findMany({ let userRequests = await prisma.request.findMany({
where: { userId: session.id }, where: { userId: session.id },
@@ -298,7 +288,7 @@ export default async function RequestPage({ searchParams }: { searchParams: Prom
/* Cascading dropdown logic */ /* Cascading dropdown logic */
#itemSelect option[data-cat] { display: none; } #itemSelect option[data-cat] { display: none; }
form:has(#categoryFilter option[value="all"]:checked) #itemSelect option[data-cat] { display: block; } 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; } form:has(#categoryFilter option[value="${cat.id}"]:checked) #itemSelect option[data-cat="${cat.id}"] { display: block; }
`).join('\n')} `).join('\n')}
`}</style> `}</style>
@@ -306,7 +296,7 @@ export default async function RequestPage({ searchParams }: { searchParams: Prom
<label className="label">Group / Category</label> <label className="label">Group / Category</label>
<select id="categoryFilter" className="input-field" defaultValue="all"> <select id="categoryFilter" className="input-field" defaultValue="all">
<option value="all">-- All Groups --</option> <option value="all">-- All Groups --</option>
{allowedCategories.map(cat => ( {categories.map(cat => (
<option key={cat.id} value={cat.id}>{cat.name}</option> <option key={cat.id} value={cat.id}>{cat.name}</option>
))} ))}
</select> </select>
@@ -330,7 +320,7 @@ export default async function RequestPage({ searchParams }: { searchParams: Prom
<label className="label">New Item Category</label> <label className="label">New Item Category</label>
<select name="categoryId" className="input-field"> <select name="categoryId" className="input-field">
<option value="">Select Category...</option> <option value="">Select Category...</option>
{allowedCategories.map(cat => ( {categories.map(cat => (
<option key={cat.id} value={cat.id}>{cat.name}</option> <option key={cat.id} value={cat.id}>{cat.name}</option>
))} ))}
</select> </select>
@@ -422,7 +412,7 @@ export default async function RequestPage({ searchParams }: { searchParams: Prom
/> />
<select name="historyGroup" className="input-field" defaultValue={historyGroup || 'all'} style={{ padding: '0.5rem 1rem', width: 'auto' }}> <select name="historyGroup" className="input-field" defaultValue={historyGroup || 'all'} style={{ padding: '0.5rem 1rem', width: 'auto' }}>
<option value="all">All Groups</option> <option value="all">All Groups</option>
{allowedCategories.map((c: any) => ( {categories.map((c: any) => (
<option key={c.id} value={c.id}>{c.name}</option> <option key={c.id} value={c.id}>{c.name}</option>
))} ))}
</select> </select>
+1
View File
@@ -39,6 +39,7 @@ export default function Navbar({ role, name, pendingCount = 0 }: { role: string,
)} )}
{role === 'super_admin' && ( {role === 'super_admin' && (
<> <>
<Link href="/admin/divisions">Divisions</Link>
<Link href="/admin/categories">Categories</Link> <Link href="/admin/categories">Categories</Link>
<Link href="/admin/users">Users</Link> <Link href="/admin/users">Users</Link>
<Link href="/admin/settings">Settings</Link> <Link href="/admin/settings">Settings</Link>