feat: restructure divisions, categories, and approval workflow
This commit is contained in:
1 parent
7345182bf5
commit
ac013f9fdb
9 files changed
+247
-93
No files matched your search
+15
-4
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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' }}>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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 (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in new issue
Block a user