This commit is contained in:
phaichayon
2026-06-11 12:46:57 +07:00
parent 1993d88306
commit 7a390cf0df
720 changed files with 100919 additions and 0 deletions

80
src/app/about/page.tsx Normal file
View File

@@ -0,0 +1,80 @@
import { Metadata } from 'next';
export const metadata: Metadata = {
title: 'About'
};
export default function AboutPage() {
return (
<div className='min-h-screen px-4 py-12 sm:px-6 lg:px-8'>
<div className='mx-auto max-w-3xl'>
{/* Header */}
<div className='mb-12 text-center'>
<h1 className='text-foreground text-3xl font-bold tracking-tight sm:text-4xl'>About</h1>
<p className='text-muted-foreground mt-4 text-lg'>Learn more about this project</p>
</div>
{/* Content Sections */}
<div className='space-y-8'>
{/* Open Source Section */}
<section className='bg-card rounded-2xl border p-8 shadow-sm'>
<h2 className='text-foreground mb-4 text-xl font-semibold'>Open-Source Project</h2>
<p className='text-muted-foreground text-lg leading-relaxed'>
This is an open-source Next.js admin dashboard starter built with modern web
technologies. It provides a solid foundation for building powerful admin interfaces
and dashboards. The source code is freely available for developers to use, modify, and
distribute.
</p>
</section>
{/* Demo Purpose Section */}
<section className='bg-card rounded-2xl border p-8 shadow-sm'>
<h2 className='text-foreground mb-4 text-xl font-semibold'>Demo Purpose</h2>
<p className='text-muted-foreground text-lg leading-relaxed'>
This application serves as a demo for demonstration purposes. It showcases the
features, components, and capabilities of the admin dashboard starter. Feel free to
explore the interface, test the functionality, and evaluate if it meets your project
requirements.
</p>
</section>
{/* Auth Section */}
<section className='bg-card rounded-2xl border p-8 shadow-sm'>
<h2 className='text-foreground mb-4 text-xl font-semibold'>Authentication by Auth.js</h2>
<p className='text-muted-foreground text-lg leading-relaxed'>
Authentication for this application is now handled by{' '}
<a
href='https://authjs.dev'
target='_blank'
rel='noopener noreferrer'
className='text-primary font-medium hover:underline'
>
Auth.js
</a>
. The app owns its user, workspace, membership, and product data model directly in
Postgres, which keeps authentication and authorization logic inside the codebase.
</p>
</section>
{/* Data Privacy Section */}
<section className='bg-card rounded-2xl border p-8 shadow-sm'>
<h2 className='text-foreground mb-4 text-xl font-semibold'>Data Privacy</h2>
<p className='text-muted-foreground text-lg leading-relaxed'>
We take your privacy seriously. No personal data is misused, shared, or sold to third
parties. Any information collected during your use of this demo application is used
solely for the purpose of providing the demonstration experience and is handled in
accordance with best practices for data protection.
</p>
</section>
</div>
{/* Footer Note */}
<div className='mt-12 text-center'>
<p className='text-muted-foreground text-sm'>
Built with Next.js, Tailwind CSS, and shadcn/ui
</p>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from 'next/server';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError } from '@/lib/auth/session';
import { assignAsset, parseAssignmentPayload, requireAssetAccess } from '../../_lib';
type Params = { params: Promise<{ id: string }> };
export async function POST(request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session } = await requireAssetAccess(PERMISSIONS.assetAssign);
const payload = parseAssignmentPayload(await request.json());
await assignAsset(organization.id, id, session.user.id, payload);
return NextResponse.json({
success: true,
message: 'Asset assigned successfully'
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to assign asset' }, { status: 500 });
}
}

View File

@@ -0,0 +1,27 @@
import { NextRequest, NextResponse } from 'next/server';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError } from '@/lib/auth/session';
import { getAssetMovements, requireAssetAccess } from '../../_lib';
type Params = { params: Promise<{ id: string }> };
export async function GET(_request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization } = await requireAssetAccess(PERMISSIONS.assetRead);
const movements = await getAssetMovements(organization.id, id);
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'Asset movements loaded successfully',
movements
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'Unable to load asset movements' }, { status: 500 });
}
}

View File

@@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from 'next/server';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError } from '@/lib/auth/session';
import { parseRepairPayload, repairAsset, requireAssetAccess } from '../../_lib';
type Params = { params: Promise<{ id: string }> };
export async function POST(request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session } = await requireAssetAccess(PERMISSIONS.assetRepair);
const payload = parseRepairPayload(await request.json());
await repairAsset(organization.id, id, session.user.id, payload);
return NextResponse.json({
success: true,
message: 'Asset repair recorded successfully'
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to record repair' }, { status: 500 });
}
}

View File

@@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from 'next/server';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError } from '@/lib/auth/session';
import { parseReturnPayload, requireAssetAccess, returnAsset } from '../../_lib';
type Params = { params: Promise<{ id: string }> };
export async function POST(request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session } = await requireAssetAccess(PERMISSIONS.assetReturn);
const payload = parseReturnPayload(await request.json());
await returnAsset(organization.id, id, session.user.id, payload);
return NextResponse.json({
success: true,
message: 'Asset returned successfully'
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to return asset' }, { status: 500 });
}
}

View File

@@ -0,0 +1,88 @@
import { and, eq } from 'drizzle-orm';
import { NextRequest, NextResponse } from 'next/server';
import { assets } from '@/db/schema';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError } from '@/lib/auth/session';
import { db } from '@/lib/db';
import {
getAssetById,
getAssetMovements,
getAssetRepairs,
parseAssetPayload,
requireAssetAccess,
updateAsset
} from '../_lib';
type Params = { params: Promise<{ id: string }> };
export async function GET(_request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization } = await requireAssetAccess(PERMISSIONS.assetRead);
const [asset, movements, repairs] = await Promise.all([
getAssetById(organization.id, id),
getAssetMovements(organization.id, id),
getAssetRepairs(organization.id, id)
]);
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'Asset loaded successfully',
asset,
movements,
repairs
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'Unable to load asset' }, { status: 500 });
}
}
export async function PUT(request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session } = await requireAssetAccess(PERMISSIONS.assetUpdate);
const payload = parseAssetPayload(await request.json());
await updateAsset(organization.id, id, session.user.id, payload);
return NextResponse.json({
success: true,
message: 'Asset updated successfully'
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to update asset' }, { status: 500 });
}
}
export async function DELETE(_request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization } = await requireAssetAccess(PERMISSIONS.assetUpdate);
await db.delete(assets).where(and(eq(assets.id, id), eq(assets.organizationId, organization.id)));
return NextResponse.json({
success: true,
message: 'Asset deleted successfully'
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'Unable to delete asset' }, { status: 500 });
}
}

View File

@@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from 'next/server';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError } from '@/lib/auth/session';
import { parseTransferPayload, requireAssetAccess, transferAsset } from '../../_lib';
type Params = { params: Promise<{ id: string }> };
export async function POST(request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session } = await requireAssetAccess(PERMISSIONS.assetTransfer);
const payload = parseTransferPayload(await request.json());
await transferAsset(organization.id, id, session.user.id, payload);
return NextResponse.json({
success: true,
message: 'Asset transferred successfully'
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to transfer asset' }, { status: 500 });
}
}

926
src/app/api/assets/_lib.ts Normal file
View File

@@ -0,0 +1,926 @@
import { and, asc, desc, eq, isNull, sql } from 'drizzle-orm';
import { z } from 'zod';
import {
assetAssignments,
assetMovements,
assetRepairs,
assets,
departments,
employees,
locations,
organizations,
sites,
users
} from '@/db/schema';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
import { db } from '@/lib/db';
import type {
Asset,
AssetAssignmentPayload,
AssetFilters,
AssetMutationPayload,
AssetOptionsResponse,
AssetRepair,
AssetRepairPayload,
AssetReturnPayload,
AssetTransferPayload,
AssetMovement
} from '@/features/assets/api/types';
const assetSchema = z.object({
assetCode: z.string().min(1),
assetName: z.string().min(1),
assetType: z.enum(['hardware', 'software']),
assetCategory: z.string().min(1),
brand: z.string().optional().or(z.literal('')),
model: z.string().optional().or(z.literal('')),
specification: z.string().optional().or(z.literal('')),
serialNumber: z.string().optional().or(z.literal('')),
siteId: z.string().optional().nullable(),
departmentId: z.string().optional().nullable(),
locationId: z.string().optional().nullable(),
currentEmployeeId: z.string().optional().nullable(),
custodianTeam: z.string().optional().or(z.literal('')),
purchaseDate: z.string().optional().nullable(),
warrantyExpiryDate: z.string().optional().nullable(),
eosDate: z.string().optional().nullable(),
eolDate: z.string().optional().nullable(),
eopDate: z.string().optional().nullable(),
status: z.enum(['AVAILABLE', 'ASSIGNED', 'IN_REPAIR', 'LOST', 'RETIRED']),
assetCondition: z.enum(['NORMAL', 'DAMAGED']),
dispositionStatus: z.enum([
'NONE',
'WAITING_REPAIR',
'WAITING_WRITE_OFF',
'WRITE_OFF_COMPLETED'
]),
notes: z.string().optional().or(z.literal(''))
});
const assignmentSchema = z.object({
employeeId: z.string().min(1),
departmentId: z.string().optional().nullable(),
locationId: z.string().optional().nullable(),
siteId: z.string().optional().nullable(),
assignDate: z.string().min(1),
documentAttachment: z.string().min(1),
reason: z.string().optional().or(z.literal(''))
});
const transferSchema = z.object({
newEmployeeId: z.string().optional().nullable(),
newDepartmentId: z.string().optional().nullable(),
newLocationId: z.string().optional().nullable(),
newSiteId: z.string().optional().nullable(),
newAssetCode: z.string().optional().or(z.literal('')),
transferDate: z.string().min(1),
referenceDocument: z.string().min(1),
reason: z.string().optional().or(z.literal(''))
});
const returnSchema = z.object({
returnDate: z.string().min(1),
assetCondition: z.enum(['NORMAL', 'DAMAGED']),
remark: z.string().min(1)
});
const repairSchema = z.object({
repairDate: z.string().optional().nullable(),
vendor: z.string().optional().or(z.literal('')),
problem: z.string().min(1),
resolution: z.string().optional().or(z.literal('')),
cost: z.number().nullable().optional(),
markAsRepair: z.boolean().optional().default(true)
});
function asDate(value?: string | null) {
if (!value) {
return null;
}
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date;
}
function iso(value: Date | string | null) {
if (!value) {
return null;
}
return value instanceof Date ? value.toISOString() : new Date(value).toISOString();
}
function sanitizeText(value?: string | null) {
return value && value.trim().length > 0 ? value.trim() : null;
}
type AssetRow = {
id: string;
organizationId: string;
assetUid: string;
assetCode: string;
assetName: string;
companyName: string | null;
assetType: string;
assetCategory: string;
brand: string | null;
model: string | null;
specification: string | null;
serialNumber: string | null;
siteId: string | null;
siteName: string | null;
departmentId: string | null;
departmentName: string | null;
locationId: string | null;
locationName: string | null;
currentEmployeeId: string | null;
currentEmployeeName: string | null;
custodianTeam: string | null;
purchaseDate: Date | null;
warrantyExpiryDate: Date | null;
eosDate: Date | null;
eolDate: Date | null;
eopDate: Date | null;
status: string;
assetCondition: string;
dispositionStatus: string;
notes: string | null;
createdAt: Date;
updatedAt: Date;
};
function formatAsset(row: AssetRow): Asset {
return {
id: row.id,
organizationId: row.organizationId,
assetUid: row.assetUid,
assetCode: row.assetCode,
assetName: row.assetName,
companyName: row.companyName,
assetType: row.assetType === 'software' ? 'software' : 'hardware',
assetCategory: row.assetCategory,
brand: row.brand,
model: row.model,
specification: row.specification,
serialNumber: row.serialNumber,
siteId: row.siteId,
siteName: row.siteName,
departmentId: row.departmentId,
departmentName: row.departmentName,
locationId: row.locationId,
locationName: row.locationName,
currentEmployeeId: row.currentEmployeeId,
currentEmployeeName: row.currentEmployeeName,
custodianTeam: row.custodianTeam,
purchaseDate: iso(row.purchaseDate),
warrantyExpiryDate: iso(row.warrantyExpiryDate),
eosDate: iso(row.eosDate),
eolDate: iso(row.eolDate),
eopDate: iso(row.eopDate),
status: (['AVAILABLE', 'ASSIGNED', 'IN_REPAIR', 'LOST', 'RETIRED'] as const).includes(
row.status as Asset['status']
)
? (row.status as Asset['status'])
: 'AVAILABLE',
assetCondition: (['NORMAL', 'DAMAGED'] as const).includes(row.assetCondition as Asset['assetCondition'])
? (row.assetCondition as Asset['assetCondition'])
: 'NORMAL',
dispositionStatus: (
['NONE', 'WAITING_REPAIR', 'WAITING_WRITE_OFF', 'WRITE_OFF_COMPLETED'] as const
).includes(row.dispositionStatus as Asset['dispositionStatus'])
? (row.dispositionStatus as Asset['dispositionStatus'])
: 'NONE',
notes: row.notes,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString()
};
}
export async function requireAssetAccess(permission: string) {
return requireOrganizationAccess({ permission });
}
export function parseAssetPayload(body: unknown): AssetMutationPayload {
const parsed = assetSchema.safeParse(body);
if (!parsed.success) {
throw new Error('Invalid asset payload');
}
return parsed.data;
}
export function parseAssignmentPayload(body: unknown): AssetAssignmentPayload {
const parsed = assignmentSchema.safeParse(body);
if (!parsed.success) {
throw new Error('Invalid assignment payload');
}
return parsed.data;
}
export function parseTransferPayload(body: unknown): AssetTransferPayload {
const parsed = transferSchema.safeParse(body);
if (!parsed.success) {
throw new Error('Invalid transfer payload');
}
return parsed.data;
}
export function parseReturnPayload(body: unknown): AssetReturnPayload {
const parsed = returnSchema.safeParse(body);
if (!parsed.success) {
throw new Error('Invalid return payload');
}
return parsed.data;
}
export function parseRepairPayload(body: unknown): AssetRepairPayload {
const parsed = repairSchema.safeParse(body);
if (!parsed.success) {
throw new Error('Invalid repair payload');
}
return parsed.data;
}
async function getBaseAssetRows(organizationId: string) {
const rows = await db
.select({
id: assets.id,
organizationId: assets.organizationId,
assetUid: assets.assetUid,
assetCode: assets.assetCode,
assetName: assets.assetName,
companyName: organizations.name,
assetType: assets.assetType,
assetCategory: assets.assetCategory,
brand: assets.brand,
model: assets.model,
specification: assets.specification,
serialNumber: assets.serialNumber,
siteId: assets.siteId,
siteName: sites.name,
departmentId: assets.departmentId,
departmentName: departments.name,
locationId: assets.locationId,
locationName: locations.name,
currentEmployeeId: assets.currentEmployeeId,
currentEmployeeName: employees.name,
custodianTeam: assets.custodianTeam,
purchaseDate: assets.purchaseDate,
warrantyExpiryDate: assets.warrantyExpiryDate,
eosDate: assets.eosDate,
eolDate: assets.eolDate,
eopDate: assets.eopDate,
status: assets.status,
assetCondition: assets.assetCondition,
dispositionStatus: assets.dispositionStatus,
notes: assets.notes,
createdAt: assets.createdAt,
updatedAt: assets.updatedAt
})
.from(assets)
.innerJoin(organizations, eq(organizations.id, assets.organizationId))
.leftJoin(sites, eq(sites.id, assets.siteId))
.leftJoin(departments, eq(departments.id, assets.departmentId))
.leftJoin(locations, eq(locations.id, assets.locationId))
.leftJoin(employees, eq(employees.id, assets.currentEmployeeId))
.where(eq(assets.organizationId, organizationId));
return rows as AssetRow[];
}
export async function listAssetsForOrganization(organizationId: string, filters: AssetFilters) {
let rows = await getBaseAssetRows(organizationId);
if (filters.search) {
const search = filters.search.toLowerCase();
rows = rows.filter((row) =>
[
row.assetUid,
row.assetCode,
row.assetName,
row.assetCategory,
row.companyName,
row.brand,
row.model,
row.serialNumber
]
.filter(Boolean)
.some((value) => String(value).toLowerCase().includes(search))
);
}
if (filters.status) {
const selected = filters.status
.split(/[.,]/)
.map((value) => value.trim())
.filter(Boolean);
rows = rows.filter((row) => selected.includes(row.status));
}
if (filters.assetType) {
const selected = filters.assetType
.split(/[.,]/)
.map((value) => value.trim())
.filter(Boolean);
rows = rows.filter((row) => selected.includes(row.assetType));
}
if (filters.assetCondition) {
const selected = filters.assetCondition
.split(/[.,]/)
.map((value) => value.trim())
.filter(Boolean);
rows = rows.filter((row) => selected.includes(row.assetCondition));
}
if (filters.dispositionStatus) {
const selected = filters.dispositionStatus
.split(/[.,]/)
.map((value) => value.trim())
.filter(Boolean);
rows = rows.filter((row) => selected.includes(row.dispositionStatus));
}
if (filters.sort) {
try {
const [primary] = JSON.parse(filters.sort) as Array<{ id: string; desc: boolean }>;
if (primary) {
rows = rows.toSorted((a, b) => {
const left = String((a as Record<string, unknown>)[primary.id] ?? '');
const right = String((b as Record<string, unknown>)[primary.id] ?? '');
return primary.desc ? right.localeCompare(left) : left.localeCompare(right);
});
}
} catch {
rows = rows.toSorted((a, b) => a.assetCode.localeCompare(b.assetCode));
}
} else {
rows = rows.toSorted((a, b) => a.assetCode.localeCompare(b.assetCode));
}
const page = filters.page ?? 1;
const limit = filters.limit ?? 10;
const offset = (page - 1) * limit;
return {
total: rows.length,
offset,
limit,
assets: rows.slice(offset, offset + limit).map(formatAsset)
};
}
export async function getAssetById(organizationId: string, assetId: string) {
const rows = await getBaseAssetRows(organizationId);
const asset = rows.find((row) => row.id === assetId);
if (!asset) {
throw new AuthError('Asset not found', 404);
}
return formatAsset(asset);
}
export async function getAssetRepairs(organizationId: string, assetId: string) {
const rows = await db.query.assetRepairs.findMany({
where: and(eq(assetRepairs.organizationId, organizationId), eq(assetRepairs.assetId, assetId)),
orderBy: desc(assetRepairs.repairDate)
});
return rows.map<AssetRepair>((row) => ({
id: row.id,
assetId: row.assetId,
repairDate: row.repairDate.toISOString(),
vendor: row.vendor,
problem: row.problem,
resolution: row.resolution,
cost: row.cost ? Number(row.cost) : null
}));
}
export async function getAssetMovements(organizationId: string, assetId?: string) {
const conditions = [eq(assetMovements.organizationId, organizationId)];
if (assetId) {
conditions.push(eq(assetMovements.assetId, assetId));
}
const rows = await db
.select({
id: assetMovements.id,
assetId: assetMovements.assetId,
assetUid: assets.assetUid,
assetCode: assets.assetCode,
assetName: assets.assetName,
eventType: assetMovements.eventType,
eventDate: assetMovements.eventDate,
reason: assetMovements.reason,
referenceDocument: assetMovements.referenceDocument,
performedByName: users.name,
metadata: assetMovements.metadata
})
.from(assetMovements)
.innerJoin(assets, eq(assets.id, assetMovements.assetId))
.leftJoin(users, eq(users.id, assetMovements.performedByUserId))
.where(and(...conditions))
.orderBy(desc(assetMovements.eventDate));
return rows.map<AssetMovement>((row) => ({
id: row.id,
assetId: row.assetId,
assetUid: row.assetUid,
assetCode: row.assetCode,
assetName: row.assetName,
eventType: row.eventType as AssetMovement['eventType'],
eventDate: row.eventDate.toISOString(),
reason: row.reason,
referenceDocument: row.referenceDocument,
performedByName: row.performedByName,
metadata: (row.metadata as Record<string, unknown> | null) ?? null
}));
}
export async function getAssetOptions(organizationId: string): Promise<AssetOptionsResponse> {
const [siteRows, departmentRows, locationRows, employeeRows] = await Promise.all([
db.select().from(sites).where(eq(sites.organizationId, organizationId)).orderBy(asc(sites.name)),
db
.select()
.from(departments)
.where(eq(departments.organizationId, organizationId))
.orderBy(asc(departments.name)),
listLocations(organizationId),
listEmployees(organizationId)
]);
return {
success: true,
time: new Date().toISOString(),
message: 'Asset options loaded successfully',
sites: siteRows.map((row) => ({
id: row.id,
organizationId: row.organizationId,
code: row.code,
name: row.name
})),
departments: departmentRows.map((row) => ({
id: row.id,
organizationId: row.organizationId,
code: row.code,
name: row.name
})),
locations: locationRows,
employees: employeeRows
};
}
async function listLocations(organizationId: string) {
const rows = await db
.select({
id: locations.id,
organizationId: locations.organizationId,
siteId: locations.siteId,
siteName: sites.name,
building: locations.building,
floor: locations.floor,
area: locations.area,
name: locations.name
})
.from(locations)
.leftJoin(sites, eq(sites.id, locations.siteId))
.where(eq(locations.organizationId, organizationId))
.orderBy(asc(locations.name));
return rows;
}
async function listEmployees(organizationId: string) {
const rows = await db
.select({
id: employees.id,
organizationId: employees.organizationId,
employeeNo: employees.employeeNo,
name: employees.name,
departmentId: employees.departmentId,
departmentName: departments.name,
siteId: employees.siteId,
siteName: sites.name,
status: employees.status
})
.from(employees)
.leftJoin(departments, eq(departments.id, employees.departmentId))
.leftJoin(sites, eq(sites.id, employees.siteId))
.where(eq(employees.organizationId, organizationId))
.orderBy(asc(employees.name));
return rows;
}
async function buildAssetUid() {
const result = await db.select({ count: sql<number>`count(*)::int` }).from(assets);
const nextValue = (result[0]?.count ?? 0) + 1;
return `AST-${String(nextValue).padStart(6, '0')}`;
}
function buildSnapshot(asset: Asset) {
return {
assetCode: asset.assetCode,
assetName: asset.assetName,
siteId: asset.siteId,
departmentId: asset.departmentId,
locationId: asset.locationId,
currentEmployeeId: asset.currentEmployeeId,
status: asset.status,
assetCondition: asset.assetCondition,
dispositionStatus: asset.dispositionStatus
};
}
async function insertMovement(tx: Pick<typeof db, 'insert'>, values: {
assetId: string;
organizationId: string;
eventType: string;
performedByUserId: string;
reason?: string | null;
referenceDocument?: string | null;
snapshotBefore?: Record<string, unknown> | null;
snapshotAfter?: Record<string, unknown> | null;
metadata?: Record<string, unknown> | null;
eventDate?: Date;
}) {
await tx.insert(assetMovements).values({
id: crypto.randomUUID(),
assetId: values.assetId,
organizationId: values.organizationId,
eventType: values.eventType,
eventDate: values.eventDate ?? new Date(),
reason: values.reason ?? null,
referenceDocument: values.referenceDocument ?? null,
performedByUserId: values.performedByUserId,
snapshotBefore: values.snapshotBefore ?? null,
snapshotAfter: values.snapshotAfter ?? null,
metadata: values.metadata ?? null
});
}
export async function createAsset(organizationId: string, performedByUserId: string, payload: AssetMutationPayload) {
const assetId = crypto.randomUUID();
const assetUid = await buildAssetUid();
await db.transaction(async (tx) => {
await tx.insert(assets).values({
id: assetId,
organizationId,
assetUid,
assetCode: payload.assetCode,
assetName: payload.assetName,
assetType: payload.assetType,
assetCategory: payload.assetCategory,
brand: sanitizeText(payload.brand),
model: sanitizeText(payload.model),
specification: sanitizeText(payload.specification),
serialNumber: sanitizeText(payload.serialNumber),
siteId: payload.siteId ?? null,
departmentId: payload.departmentId ?? null,
locationId: payload.locationId ?? null,
currentEmployeeId: payload.currentEmployeeId ?? null,
custodianTeam: sanitizeText(payload.custodianTeam),
purchaseDate: asDate(payload.purchaseDate),
warrantyExpiryDate: asDate(payload.warrantyExpiryDate),
eosDate: asDate(payload.eosDate),
eolDate: asDate(payload.eolDate),
eopDate: asDate(payload.eopDate),
status: payload.status,
assetCondition: payload.assetCondition,
dispositionStatus: payload.dispositionStatus,
notes: sanitizeText(payload.notes)
});
await insertMovement(tx, {
assetId,
organizationId,
eventType: 'CREATE',
performedByUserId,
snapshotAfter: {
assetCode: payload.assetCode,
assetName: payload.assetName,
status: payload.status,
assetCondition: payload.assetCondition,
dispositionStatus: payload.dispositionStatus,
assetUid
}
});
});
return assetId;
}
export async function updateAsset(
organizationId: string,
assetId: string,
performedByUserId: string,
payload: AssetMutationPayload
) {
const currentAsset = await getAssetById(organizationId, assetId);
await db.transaction(async (tx) => {
await tx
.update(assets)
.set({
assetCode: payload.assetCode,
assetName: payload.assetName,
assetType: payload.assetType,
assetCategory: payload.assetCategory,
brand: sanitizeText(payload.brand),
model: sanitizeText(payload.model),
specification: sanitizeText(payload.specification),
serialNumber: sanitizeText(payload.serialNumber),
siteId: payload.siteId ?? null,
departmentId: payload.departmentId ?? null,
locationId: payload.locationId ?? null,
currentEmployeeId: payload.currentEmployeeId ?? null,
custodianTeam: sanitizeText(payload.custodianTeam),
purchaseDate: asDate(payload.purchaseDate),
warrantyExpiryDate: asDate(payload.warrantyExpiryDate),
eosDate: asDate(payload.eosDate),
eolDate: asDate(payload.eolDate),
eopDate: asDate(payload.eopDate),
status: payload.status,
assetCondition: payload.assetCondition,
dispositionStatus: payload.dispositionStatus,
notes: sanitizeText(payload.notes),
updatedAt: new Date()
})
.where(and(eq(assets.id, assetId), eq(assets.organizationId, organizationId)));
const updatedAsset = await getAssetById(organizationId, assetId);
await insertMovement(tx, {
assetId,
organizationId,
eventType: 'UPDATE',
performedByUserId,
snapshotBefore: buildSnapshot(currentAsset),
snapshotAfter: buildSnapshot(updatedAsset)
});
});
}
export async function assignAsset(
organizationId: string,
assetId: string,
performedByUserId: string,
payload: AssetAssignmentPayload
) {
const currentAsset = await getAssetById(organizationId, assetId);
await db.transaction(async (tx) => {
await tx.insert(assetAssignments).values({
id: crypto.randomUUID(),
assetId,
organizationId,
employeeId: payload.employeeId,
departmentId: payload.departmentId ?? currentAsset.departmentId,
locationId: payload.locationId ?? currentAsset.locationId,
siteId: payload.siteId ?? currentAsset.siteId,
assignedAt: asDate(payload.assignDate) ?? new Date(),
documentAttachment: sanitizeText(payload.documentAttachment),
assignedByUserId: performedByUserId
});
await tx
.update(assets)
.set({
currentEmployeeId: payload.employeeId,
departmentId: payload.departmentId ?? currentAsset.departmentId,
locationId: payload.locationId ?? currentAsset.locationId,
siteId: payload.siteId ?? currentAsset.siteId,
status: 'ASSIGNED',
dispositionStatus: 'NONE',
updatedAt: new Date()
})
.where(and(eq(assets.id, assetId), eq(assets.organizationId, organizationId)));
const updatedAsset = await getAssetById(organizationId, assetId);
await insertMovement(tx, {
assetId,
organizationId,
eventType: 'ASSIGN',
performedByUserId,
reason: payload.reason ?? null,
referenceDocument: payload.documentAttachment,
eventDate: asDate(payload.assignDate) ?? new Date(),
snapshotBefore: buildSnapshot(currentAsset),
snapshotAfter: buildSnapshot(updatedAsset),
metadata: {
employeeId: payload.employeeId,
departmentId: payload.departmentId ?? null,
locationId: payload.locationId ?? null,
siteId: payload.siteId ?? null,
documentAttachment: payload.documentAttachment
}
});
});
}
export async function transferAsset(
organizationId: string,
assetId: string,
performedByUserId: string,
payload: AssetTransferPayload
) {
const currentAsset = await getAssetById(organizationId, assetId);
const transferEventDate = asDate(payload.transferDate) ?? new Date();
const nextAssetCode = sanitizeText(payload.newAssetCode) ?? currentAsset.assetCode;
const nextEmployeeId = payload.newEmployeeId ?? currentAsset.currentEmployeeId;
const nextDepartmentId = payload.newDepartmentId ?? currentAsset.departmentId;
const nextLocationId = payload.newLocationId ?? currentAsset.locationId;
const nextSiteId = payload.newSiteId ?? currentAsset.siteId;
await db.transaction(async (tx) => {
await tx
.update(assetAssignments)
.set({
returnedAt: transferEventDate,
returnedByUserId: performedByUserId,
updatedAt: new Date()
})
.where(
and(
eq(assetAssignments.assetId, assetId),
eq(assetAssignments.organizationId, organizationId),
isNull(assetAssignments.returnedAt)
)
);
if (nextEmployeeId) {
await tx.insert(assetAssignments).values({
id: crypto.randomUUID(),
assetId,
organizationId,
employeeId: nextEmployeeId,
departmentId: nextDepartmentId,
locationId: nextLocationId,
siteId: nextSiteId,
assignedAt: transferEventDate,
documentAttachment: sanitizeText(payload.referenceDocument),
assignedByUserId: performedByUserId
});
}
await tx
.update(assets)
.set({
currentEmployeeId: nextEmployeeId,
departmentId: nextDepartmentId,
locationId: nextLocationId,
siteId: nextSiteId,
assetCode: nextAssetCode,
status: nextEmployeeId ? 'ASSIGNED' : 'AVAILABLE',
updatedAt: new Date()
})
.where(and(eq(assets.id, assetId), eq(assets.organizationId, organizationId)));
const updatedAsset = await getAssetById(organizationId, assetId);
await insertMovement(tx, {
assetId,
organizationId,
eventType: 'TRANSFER',
performedByUserId,
reason: payload.reason ?? null,
referenceDocument: payload.referenceDocument,
eventDate: transferEventDate,
snapshotBefore: buildSnapshot(currentAsset),
snapshotAfter: buildSnapshot(updatedAsset),
metadata: {
oldEmployeeId: currentAsset.currentEmployeeId,
newEmployeeId: nextEmployeeId,
oldDepartmentId: currentAsset.departmentId,
newDepartmentId: nextDepartmentId,
oldLocationId: currentAsset.locationId,
newLocationId: nextLocationId,
oldSiteId: currentAsset.siteId,
newSiteId: nextSiteId,
oldAssetCode: currentAsset.assetCode,
newAssetCode: nextAssetCode
}
});
});
}
export async function returnAsset(
organizationId: string,
assetId: string,
performedByUserId: string,
payload: AssetReturnPayload
) {
const currentAsset = await getAssetById(organizationId, assetId);
const returnEventDate = asDate(payload.returnDate) ?? new Date();
const nextDispositionStatus = payload.assetCondition === 'DAMAGED' ? 'WAITING_REPAIR' : 'NONE';
await db.transaction(async (tx) => {
await tx
.update(assetAssignments)
.set({
returnedAt: returnEventDate,
returnedByUserId: performedByUserId,
updatedAt: new Date()
})
.where(
and(
eq(assetAssignments.assetId, assetId),
eq(assetAssignments.organizationId, organizationId),
isNull(assetAssignments.returnedAt)
)
);
await tx
.update(assets)
.set({
currentEmployeeId: null,
status: 'AVAILABLE',
assetCondition: payload.assetCondition,
dispositionStatus: nextDispositionStatus,
updatedAt: new Date()
})
.where(and(eq(assets.id, assetId), eq(assets.organizationId, organizationId)));
const updatedAsset = await getAssetById(organizationId, assetId);
await insertMovement(tx, {
assetId,
organizationId,
eventType: 'RETURN',
performedByUserId,
reason: payload.remark,
eventDate: returnEventDate,
snapshotBefore: buildSnapshot(currentAsset),
snapshotAfter: buildSnapshot(updatedAsset),
metadata: {
assetCondition: payload.assetCondition,
dispositionStatus: nextDispositionStatus
}
});
});
}
export async function repairAsset(
organizationId: string,
assetId: string,
performedByUserId: string,
payload: AssetRepairPayload
) {
const currentAsset = await getAssetById(organizationId, assetId);
await db.transaction(async (tx) => {
await tx.insert(assetRepairs).values({
id: crypto.randomUUID(),
assetId,
organizationId,
repairDate: asDate(payload.repairDate) ?? new Date(),
vendor: sanitizeText(payload.vendor),
problem: payload.problem,
resolution: sanitizeText(payload.resolution),
cost: payload.cost != null ? String(payload.cost) : null,
createdByUserId: performedByUserId
});
if (payload.markAsRepair ?? true) {
await tx
.update(assets)
.set({
status: 'IN_REPAIR',
dispositionStatus: 'WAITING_REPAIR',
updatedAt: new Date()
})
.where(and(eq(assets.id, assetId), eq(assets.organizationId, organizationId)));
}
const updatedAsset = await getAssetById(organizationId, assetId);
await insertMovement(tx, {
assetId,
organizationId,
eventType: 'REPAIR',
performedByUserId,
eventDate: asDate(payload.repairDate) ?? new Date(),
snapshotBefore: buildSnapshot(currentAsset),
snapshotAfter: buildSnapshot(updatedAsset),
metadata: {
vendor: payload.vendor ?? null,
problem: payload.problem,
resolution: payload.resolution ?? null,
cost: payload.cost ?? null
}
});
});
}

View File

@@ -0,0 +1,65 @@
import { startOfMonth } from 'date-fns';
import { NextResponse } from 'next/server';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError } from '@/lib/auth/session';
import {
getAssetMovements,
listAssetsForOrganization,
requireAssetAccess
} from '../../_lib';
export async function GET() {
try {
const { organization } = await requireAssetAccess(PERMISSIONS.assetRead);
const [result, movements] = await Promise.all([
listAssetsForOrganization(organization.id, {
page: 1,
limit: 10000
}),
getAssetMovements(organization.id)
]);
const monthStart = startOfMonth(new Date());
const summary = result.assets.reduce(
(acc, asset) => {
acc.cards.totalAssets += 1;
if (asset.status === 'ASSIGNED') acc.cards.assignedAssets += 1;
if (asset.status === 'AVAILABLE') acc.cards.inStockAssets += 1;
if (!asset.currentEmployeeId) acc.cards.withoutUser += 1;
if (!asset.locationId) acc.cards.withoutLocation += 1;
return acc;
},
{
cards: {
totalAssets: 0,
assignedAssets: 0,
inStockAssets: 0,
transferThisMonth: 0,
withoutUser: 0,
withoutLocation: 0
}
}
);
summary.cards.transferThisMonth = movements.filter((movement) => {
return (
movement.eventType === 'TRANSFER' && new Date(movement.eventDate).getTime() >= monthStart.getTime()
);
}).length;
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'Asset dashboard summary loaded successfully',
...summary
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'Unable to load dashboard summary' }, { status: 500 });
}
}

View File

@@ -0,0 +1,24 @@
import { NextRequest, NextResponse } from 'next/server';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError } from '@/lib/auth/session';
import { getAssetMovements, requireAssetAccess } from '../_lib';
export async function GET(_request: NextRequest) {
try {
const { organization } = await requireAssetAccess(PERMISSIONS.assetRead);
const movements = await getAssetMovements(organization.id);
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'Asset history loaded successfully',
movements
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'Unable to load asset history' }, { status: 500 });
}
}

View File

@@ -0,0 +1,19 @@
import { NextResponse } from 'next/server';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError } from '@/lib/auth/session';
import { getAssetOptions, requireAssetAccess } from '../_lib';
export async function GET() {
try {
const { organization } = await requireAssetAccess(PERMISSIONS.assetRead);
const response = await getAssetOptions(organization.id);
return NextResponse.json(response);
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'Unable to load asset options' }, { status: 500 });
}
}

View File

@@ -0,0 +1,70 @@
import { NextRequest, NextResponse } from 'next/server';
import {
createAsset,
listAssetsForOrganization,
parseAssetPayload,
requireAssetAccess
} from './_lib';
import { AuthError } from '@/lib/auth/session';
import { PERMISSIONS } from '@/lib/auth/rbac';
export async function GET(request: NextRequest) {
try {
const { organization } = await requireAssetAccess(PERMISSIONS.assetRead);
const { searchParams } = request.nextUrl;
const result = await listAssetsForOrganization(organization.id, {
page: Number(searchParams.get('page') ?? 1),
limit: Number(searchParams.get('limit') ?? 10),
search: searchParams.get('search') ?? undefined,
status: searchParams.get('status') ?? undefined,
assetCondition: searchParams.get('assetCondition') ?? undefined,
dispositionStatus: searchParams.get('dispositionStatus') ?? undefined,
assetType: searchParams.get('assetType') ?? undefined,
sort: searchParams.get('sort') ?? undefined
});
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'Assets loaded successfully',
total_assets: result.total,
offset: result.offset,
limit: result.limit,
assets: result.assets
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'Unable to load assets' }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
try {
const { organization, session } = await requireAssetAccess(PERMISSIONS.assetCreate);
const payload = parseAssetPayload(await request.json());
const id = await createAsset(organization.id, session.user.id, payload);
return NextResponse.json(
{
success: true,
message: 'Asset created successfully',
id
},
{ status: 201 }
);
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to create asset' }, { status: 500 });
}
}

View File

@@ -0,0 +1,3 @@
import { handlers } from '@/auth';
export const { GET, POST } = handlers;

View File

@@ -0,0 +1,10 @@
import { NextResponse } from 'next/server';
export async function POST() {
return NextResponse.json(
{
message: 'Self-service sign-up is disabled. Please contact your administrator.'
},
{ status: 403 }
);
}

View File

@@ -0,0 +1,79 @@
import { NextRequest, NextResponse } from 'next/server';
import { exampleProducts } from '@/features/example-dashboard/data';
function parseCategories(value: string | null) {
if (!value) return [];
return value
.split(/[.,]/)
.map((item) => item.trim())
.filter(Boolean);
}
function sortProducts(
items: typeof exampleProducts,
sort: string | null
) {
const defaultSorted = items.toSorted((a, b) => a.name.localeCompare(b.name));
if (!sort) {
return defaultSorted;
}
try {
const parsed = JSON.parse(sort) as Array<{ id: keyof (typeof exampleProducts)[number]; desc: boolean }>;
const [primarySort] = parsed;
if (!primarySort?.id) {
return defaultSorted;
}
const sorted = items.toSorted((left, right) => {
const a = left[primarySort.id];
const b = right[primarySort.id];
if (typeof a === 'number' && typeof b === 'number') {
return a - b;
}
return String(a).localeCompare(String(b));
});
return primarySort.desc ? sorted.toReversed() : sorted;
} catch {
return defaultSorted;
}
}
export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl;
const page = Number(searchParams.get('page') ?? 1);
const limit = Number(searchParams.get('limit') ?? 10);
const categories = parseCategories(searchParams.get('categories'));
const search = searchParams.get('search')?.trim().toLowerCase();
const sort = searchParams.get('sort');
const filtered = exampleProducts.filter((product) => {
const matchesCategory = categories.length ? categories.includes(product.category) : true;
const matchesSearch = search
? [product.name, product.description, product.category].some((value) =>
value.toLowerCase().includes(search)
)
: true;
return matchesCategory && matchesSearch;
});
const sorted = sortProducts(filtered, sort);
const offset = (page - 1) * limit;
const products = sorted.slice(offset, offset + limit);
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'Example products loaded successfully',
total_products: sorted.length,
offset,
limit,
products
});
}

View File

@@ -0,0 +1,86 @@
import { NextRequest, NextResponse } from 'next/server';
import { exampleUsers } from '@/features/example-dashboard/data';
function parseRoles(value: string | null) {
if (!value) return [];
return value
.split(/[.,]/)
.map((item) => item.trim())
.filter(Boolean);
}
function sortUsers(
items: typeof exampleUsers,
sort: string | null
) {
const defaultSorted = items.toSorted((a, b) => a.name.localeCompare(b.name));
if (!sort) {
return defaultSorted;
}
try {
const parsed = JSON.parse(sort) as Array<{ id: string; desc: boolean }>;
const [primarySort] = parsed;
if (!primarySort?.id) {
return defaultSorted;
}
const sorted = items.toSorted((left, right) => {
const a =
primarySort.id === 'role'
? `${left.systemRole}:${left.activeMembershipRole ?? ''}`
: primarySort.id === 'organizations'
? left.organizations.map((organization) => organization.name).join(', ')
: left.name;
const b =
primarySort.id === 'role'
? `${right.systemRole}:${right.activeMembershipRole ?? ''}`
: primarySort.id === 'organizations'
? right.organizations.map((organization) => organization.name).join(', ')
: right.name;
return a.localeCompare(b);
});
return primarySort.desc ? sorted.toReversed() : sorted;
} catch {
return defaultSorted;
}
}
export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl;
const page = Number(searchParams.get('page') ?? 1);
const limit = Number(searchParams.get('limit') ?? 10);
const roles = parseRoles(searchParams.get('roles'));
const search = searchParams.get('search')?.trim().toLowerCase();
const sort = searchParams.get('sort');
const filtered = exampleUsers.filter((user) => {
const matchesSearch = search
? [user.name, user.email].some((value) => value.toLowerCase().includes(search))
: true;
const matchesRole = roles.length
? roles.includes(user.systemRole) ||
user.memberships.some((membership) => roles.includes(membership.membershipRole))
: true;
return matchesSearch && matchesRole;
});
const sorted = sortUsers(filtered, sort);
const offset = (page - 1) * limit;
const users = sorted.slice(offset, offset + limit);
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'Example users loaded successfully',
total_users: sorted.length,
offset,
limit,
users
});
}

View File

@@ -0,0 +1,58 @@
import { NextRequest, NextResponse } from 'next/server';
import { AuthError } from '@/lib/auth/session';
import {
assertMasterDataEntity,
deleteMasterData,
parseMasterDataPayload,
requireMasterDataAccess,
updateMasterData
} from '../../_lib';
type Params = { params: Promise<{ entity: string; id: string }> };
export async function PUT(request: NextRequest, { params }: Params) {
try {
const { entity: rawEntity, id } = await params;
const entity = assertMasterDataEntity(rawEntity);
const { organization } = await requireMasterDataAccess();
const payload = parseMasterDataPayload(entity, await request.json());
await updateMasterData(entity, organization.id, id, payload);
return NextResponse.json({
success: true,
message: `${entity} updated successfully`
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to update master data' }, { status: 500 });
}
}
export async function DELETE(_request: NextRequest, { params }: Params) {
try {
const { entity: rawEntity, id } = await params;
const entity = assertMasterDataEntity(rawEntity);
const { organization } = await requireMasterDataAccess();
await deleteMasterData(entity, organization.id, id);
return NextResponse.json({
success: true,
message: `${entity} deleted successfully`
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'Unable to delete master data' }, { status: 500 });
}
}

View File

@@ -0,0 +1,62 @@
import { NextRequest, NextResponse } from 'next/server';
import { AuthError } from '@/lib/auth/session';
import {
assertMasterDataEntity,
createMasterData,
listMasterData,
parseMasterDataPayload,
requireMasterDataAccess
} from '../_lib';
type Params = { params: Promise<{ entity: string }> };
export async function GET(_request: NextRequest, { params }: Params) {
try {
const { entity: rawEntity } = await params;
const entity = assertMasterDataEntity(rawEntity);
const { organization } = await requireMasterDataAccess();
const items = await listMasterData(entity, organization.id);
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: `${entity} loaded successfully`,
items
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'Unable to load master data' }, { status: 500 });
}
}
export async function POST(request: NextRequest, { params }: Params) {
try {
const { entity: rawEntity } = await params;
const entity = assertMasterDataEntity(rawEntity);
const { organization } = await requireMasterDataAccess();
const payload = parseMasterDataPayload(entity, await request.json());
const id = await createMasterData(entity, organization.id, payload);
return NextResponse.json(
{
success: true,
message: `${entity} created successfully`,
id
},
{ status: 201 }
);
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to create master data' }, { status: 500 });
}
}

View File

@@ -0,0 +1,275 @@
import { and, eq } from 'drizzle-orm';
import { z } from 'zod';
import { departments, employees, locations, sites } from '@/db/schema';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { requireOrganizationAccess } from '@/lib/auth/session';
import { db } from '@/lib/db';
import type {
Department,
Employee,
Location,
MasterDataEntity,
MasterDataMutationPayload,
Site
} from '@/features/assets/api/types';
const masterDataEntities = ['sites', 'departments', 'locations', 'employees'] as const;
const entitySchemaMap = {
sites: z.object({
code: z.string().min(1),
name: z.string().min(1)
}),
departments: z.object({
code: z.string().min(1),
name: z.string().min(1)
}),
locations: z.object({
name: z.string().min(1),
siteId: z.string().optional().nullable(),
building: z.string().optional(),
floor: z.string().optional(),
area: z.string().optional()
}),
employees: z.object({
employeeNo: z.string().min(1),
name: z.string().min(1),
departmentId: z.string().optional().nullable(),
siteId: z.string().optional().nullable(),
status: z.string().default('active')
})
} as const;
export async function requireMasterDataAccess() {
return requireOrganizationAccess({ permission: PERMISSIONS.masterDataManage });
}
export function assertMasterDataEntity(entity: string): MasterDataEntity {
if ((masterDataEntities as readonly string[]).includes(entity)) {
return entity as MasterDataEntity;
}
throw new Error('Invalid master data entity');
}
export function parseMasterDataPayload(
entity: MasterDataEntity,
body: unknown
): MasterDataMutationPayload {
const schema = entitySchemaMap[entity];
const parsed = schema.safeParse(body);
if (!parsed.success) {
throw new Error('Invalid master data payload');
}
return parsed.data;
}
export async function listMasterData(entity: MasterDataEntity, organizationId: string) {
switch (entity) {
case 'sites': {
const rows = await db.query.sites.findMany({
where: eq(sites.organizationId, organizationId)
});
return rows.map<Site>((row) => ({
id: row.id,
organizationId: row.organizationId,
code: row.code,
name: row.name
}));
}
case 'departments': {
const rows = await db.query.departments.findMany({
where: eq(departments.organizationId, organizationId)
});
return rows.map<Department>((row) => ({
id: row.id,
organizationId: row.organizationId,
code: row.code,
name: row.name
}));
}
case 'locations': {
const rows = await db
.select({
id: locations.id,
organizationId: locations.organizationId,
siteId: locations.siteId,
siteName: sites.name,
building: locations.building,
floor: locations.floor,
area: locations.area,
name: locations.name
})
.from(locations)
.leftJoin(sites, eq(sites.id, locations.siteId))
.where(eq(locations.organizationId, organizationId));
return rows.map<Location>((row) => ({
id: row.id,
organizationId: row.organizationId,
siteId: row.siteId,
siteName: row.siteName,
building: row.building,
floor: row.floor,
area: row.area,
name: row.name
}));
}
case 'employees': {
const rows = await db
.select({
id: employees.id,
organizationId: employees.organizationId,
employeeNo: employees.employeeNo,
name: employees.name,
departmentId: employees.departmentId,
departmentName: departments.name,
siteId: employees.siteId,
siteName: sites.name,
status: employees.status
})
.from(employees)
.leftJoin(departments, eq(departments.id, employees.departmentId))
.leftJoin(sites, eq(sites.id, employees.siteId))
.where(eq(employees.organizationId, organizationId));
return rows.map<Employee>((row) => ({
id: row.id,
organizationId: row.organizationId,
employeeNo: row.employeeNo,
name: row.name,
departmentId: row.departmentId,
departmentName: row.departmentName,
siteId: row.siteId,
siteName: row.siteName,
status: row.status
}));
}
}
}
export async function createMasterData(
entity: MasterDataEntity,
organizationId: string,
payload: MasterDataMutationPayload
) {
const id = crypto.randomUUID();
switch (entity) {
case 'sites':
await db.insert(sites).values({
id,
organizationId,
code: payload.code!,
name: payload.name
});
break;
case 'departments':
await db.insert(departments).values({
id,
organizationId,
code: payload.code!,
name: payload.name
});
break;
case 'locations':
await db.insert(locations).values({
id,
organizationId,
siteId: payload.siteId ?? null,
building: payload.building ?? null,
floor: payload.floor ?? null,
area: payload.area ?? null,
name: payload.name
});
break;
case 'employees':
await db.insert(employees).values({
id,
organizationId,
employeeNo: payload.employeeNo!,
name: payload.name,
departmentId: payload.departmentId ?? null,
siteId: payload.siteId ?? null,
status: payload.status ?? 'active'
});
break;
}
return id;
}
export async function updateMasterData(
entity: MasterDataEntity,
organizationId: string,
id: string,
payload: MasterDataMutationPayload
) {
switch (entity) {
case 'sites':
await db
.update(sites)
.set({ code: payload.code!, name: payload.name, updatedAt: new Date() })
.where(and(eq(sites.id, id), eq(sites.organizationId, organizationId)));
break;
case 'departments':
await db
.update(departments)
.set({ code: payload.code!, name: payload.name, updatedAt: new Date() })
.where(and(eq(departments.id, id), eq(departments.organizationId, organizationId)));
break;
case 'locations':
await db
.update(locations)
.set({
siteId: payload.siteId ?? null,
building: payload.building ?? null,
floor: payload.floor ?? null,
area: payload.area ?? null,
name: payload.name,
updatedAt: new Date()
})
.where(and(eq(locations.id, id), eq(locations.organizationId, organizationId)));
break;
case 'employees':
await db
.update(employees)
.set({
employeeNo: payload.employeeNo!,
name: payload.name,
departmentId: payload.departmentId ?? null,
siteId: payload.siteId ?? null,
status: payload.status ?? 'active',
updatedAt: new Date()
})
.where(and(eq(employees.id, id), eq(employees.organizationId, organizationId)));
break;
}
}
export async function deleteMasterData(entity: MasterDataEntity, organizationId: string, id: string) {
switch (entity) {
case 'sites':
await db.delete(sites).where(and(eq(sites.id, id), eq(sites.organizationId, organizationId)));
break;
case 'departments':
await db
.delete(departments)
.where(and(eq(departments.id, id), eq(departments.organizationId, organizationId)));
break;
case 'locations':
await db
.delete(locations)
.where(and(eq(locations.id, id), eq(locations.organizationId, organizationId)));
break;
case 'employees':
await db
.delete(employees)
.where(and(eq(employees.id, id), eq(employees.organizationId, organizationId)));
break;
}
}

View File

@@ -0,0 +1,51 @@
import { and, eq } from 'drizzle-orm';
import { NextRequest, NextResponse } from 'next/server';
import { memberships, organizations, users } from '@/db/schema';
import { db } from '@/lib/db';
import { AuthError, requireSession } from '@/lib/auth/session';
export async function PATCH(request: NextRequest) {
try {
const session = await requireSession();
const body = (await request.json()) as { organizationId?: string };
const organizationId = body.organizationId;
if (!organizationId) {
return NextResponse.json({ message: 'organizationId is required' }, { status: 400 });
}
if (session.user.systemRole !== 'super_admin') {
const membership = await db.query.memberships.findFirst({
where: and(
eq(memberships.userId, session.user.id),
eq(memberships.organizationId, organizationId)
)
});
if (!membership) {
return NextResponse.json({ message: 'Forbidden' }, { status: 403 });
}
} else {
const organization = await db.query.organizations.findFirst({
where: eq(organizations.id, organizationId)
});
if (!organization) {
return NextResponse.json({ message: 'Organization not found' }, { status: 404 });
}
}
await db
.update(users)
.set({ activeOrganizationId: organizationId })
.where(eq(users.id, session.user.id));
return NextResponse.json({ success: true });
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'Unable to switch organization' }, { status: 500 });
}
}

View File

@@ -0,0 +1,132 @@
import { eq, inArray } from 'drizzle-orm';
import { NextRequest, NextResponse } from 'next/server';
import { memberships, organizations, users } from '@/db/schema';
import { getDefaultPermissions } from '@/lib/auth/rbac';
import { AuthError, requireSession, requireSystemRole } from '@/lib/auth/session';
import { db } from '@/lib/db';
function slugifyWorkspaceName(value: string) {
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
export async function GET(request: NextRequest) {
try {
const session = await requireSession();
const scope = request.nextUrl.searchParams.get('scope');
if (session.user.systemRole === 'super_admin') {
const rows = await db.select().from(organizations);
return NextResponse.json({
organizations: rows.map((organization) => ({
id: organization.id,
name: organization.name,
slug: organization.slug,
plan: organization.plan,
imageUrl: organization.imageUrl,
role: 'admin',
businessRole: 'it_admin',
canManageUsers: true
}))
});
}
const membershipRows = await db.query.memberships.findMany({
where: eq(memberships.userId, session.user.id)
});
const filteredMemberships =
scope === 'manageable'
? membershipRows.filter((membership) => membership.role === 'admin')
: membershipRows;
if (!filteredMemberships.length) {
return NextResponse.json({ organizations: [] });
}
const organizationRows = await db
.select()
.from(organizations)
.where(inArray(organizations.id, filteredMemberships.map((membership) => membership.organizationId)));
const membershipMap = new Map(
filteredMemberships.map((membership) => [membership.organizationId, membership])
);
return NextResponse.json({
organizations: organizationRows.map((organization) => {
const membership = membershipMap.get(organization.id);
return {
id: organization.id,
name: organization.name,
slug: organization.slug,
plan: organization.plan,
imageUrl: organization.imageUrl,
role: membership?.role ?? 'user',
businessRole: membership?.businessRole ?? 'viewer',
canManageUsers: membership?.role === 'admin'
};
})
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'Unable to load organizations' }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
try {
const session = await requireSystemRole('super_admin');
const body = (await request.json()) as { name?: string };
const name = body.name?.trim();
if (!name) {
return NextResponse.json({ message: 'Workspace name is required' }, { status: 400 });
}
const organizationId = crypto.randomUUID();
const baseSlug = slugifyWorkspaceName(name);
const slug = `${baseSlug || 'workspace'}-${organizationId.slice(0, 6)}`;
await db.insert(organizations).values({
id: organizationId,
name,
slug,
plan: 'free',
createdBy: session.user.id
});
await db.insert(memberships).values({
id: crypto.randomUUID(),
userId: session.user.id,
organizationId,
role: 'admin',
businessRole: 'it_admin',
permissions: getDefaultPermissions('admin', 'it_admin')
});
await db
.update(users)
.set({ activeOrganizationId: organizationId })
.where(eq(users.id, session.user.id));
return NextResponse.json({
success: true,
organizationId
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'Unable to create organization' }, { status: 500 });
}
}

View File

@@ -0,0 +1,128 @@
import { and, eq, type InferSelectModel } from 'drizzle-orm';
import { NextRequest, NextResponse } from 'next/server';
import { products } from '@/db/schema';
import type { ProductMutationPayload } from '@/features/products/api/types';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
import { db } from '@/lib/db';
type Params = { params: Promise<{ id: string }> };
function toProductResponse(product: InferSelectModel<typeof products>) {
return {
id: product.id,
organization_id: product.organizationId,
photo_url: product.photoUrl,
name: product.name,
description: product.description,
created_at: product.createdAt.toISOString(),
price: product.price,
category: product.category,
updated_at: product.updatedAt.toISOString()
};
}
export async function GET(request: NextRequest, { params }: Params) {
try {
const { organization } = await requireOrganizationAccess();
const { id } = await params;
const product = await db.query.products.findFirst({
where: and(eq(products.id, Number(id)), eq(products.organizationId, organization.id))
});
if (!product) {
return NextResponse.json(
{ success: false, message: `Product with ID ${id} not found` },
{ status: 404 }
);
}
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: `Product with ID ${id} found`,
product: toProductResponse(product)
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'Unable to load product' }, { status: 500 });
}
}
export async function PATCH(request: NextRequest, { params }: Params) {
try {
const { organization } = await requireOrganizationAccess();
const { id } = await params;
const body = (await request.json()) as ProductMutationPayload;
const existingProduct = await db.query.products.findFirst({
where: and(eq(products.id, Number(id)), eq(products.organizationId, organization.id))
});
if (!existingProduct) {
return NextResponse.json(
{ success: false, message: `Product with ID ${id} not found` },
{ status: 404 }
);
}
const [product] = await db
.update(products)
.set({
name: body.name,
category: body.category,
price: body.price,
description: body.description,
photoUrl: body.photoDataUrl || existingProduct.photoUrl,
updatedAt: new Date()
})
.where(and(eq(products.id, Number(id)), eq(products.organizationId, organization.id)))
.returning();
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'Product updated successfully',
product: toProductResponse(product)
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'Unable to update product' }, { status: 500 });
}
}
export async function DELETE(request: NextRequest, { params }: Params) {
try {
const { organization } = await requireOrganizationAccess();
const { id } = await params;
const [deletedProduct] = await db
.delete(products)
.where(and(eq(products.id, Number(id)), eq(products.organizationId, organization.id)))
.returning({ id: products.id });
if (!deletedProduct) {
return NextResponse.json(
{ success: false, message: `Product with ID ${id} not found` },
{ status: 404 }
);
}
return NextResponse.json({
success: true,
message: 'Product deleted successfully'
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'Unable to delete product' }, { status: 500 });
}
}

View File

@@ -0,0 +1,146 @@
import { and, asc, count, desc, eq, ilike, inArray, or } from 'drizzle-orm';
import { NextRequest, NextResponse } from 'next/server';
import { products } from '@/db/schema';
import type { ProductMutationPayload } from '@/features/products/api/types';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
import { db } from '@/lib/db';
const sortMap = {
name: products.name,
category: products.category,
price: products.price,
created_at: products.createdAt,
updated_at: products.updatedAt
} as const;
function buildProductImageUrl(name: string) {
return `https://placehold.co/120x120/336b4a/ffffff/png?text=${encodeURIComponent(name.slice(0, 12))}`;
}
export async function GET(request: NextRequest) {
try {
const { organization } = await requireOrganizationAccess();
const { searchParams } = request.nextUrl;
const page = Number(searchParams.get('page') ?? 1);
const limit = Number(searchParams.get('limit') ?? 10);
const categories = searchParams.get('categories');
const search = searchParams.get('search');
const sort = searchParams.get('sort');
const categoryValues = categories
? categories
.split(/[.,]/)
.map((value) => value.trim())
.filter(Boolean)
: [];
const filters = [
eq(products.organizationId, organization.id),
...(categoryValues.length ? [inArray(products.category, categoryValues)] : []),
...(search
? [
or(
ilike(products.name, `%${search}%`),
ilike(products.description, `%${search}%`),
ilike(products.category, `%${search}%`)
)!
]
: [])
];
const where = filters.length === 1 ? filters[0] : and(...filters);
const [totalResult] = await db.select({ value: count() }).from(products).where(where);
let orderByClause = desc(products.createdAt);
if (sort) {
try {
const sortItems = JSON.parse(sort) as Array<{ id: keyof typeof sortMap; desc: boolean }>;
const primarySort = sortItems[0];
if (primarySort?.id && sortMap[primarySort.id]) {
orderByClause = primarySort.desc
? desc(sortMap[primarySort.id])
: asc(sortMap[primarySort.id]);
}
} catch {
orderByClause = desc(products.createdAt);
}
}
const offset = (page - 1) * limit;
const rows = await db.select().from(products).where(where).orderBy(orderByClause).limit(limit).offset(offset);
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'Products loaded successfully',
total_products: totalResult?.value ?? 0,
offset,
limit,
products: rows.map((product) => ({
id: product.id,
organization_id: product.organizationId,
photo_url: product.photoUrl,
name: product.name,
description: product.description,
created_at: product.createdAt.toISOString(),
price: product.price,
category: product.category,
updated_at: product.updatedAt.toISOString()
}))
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'Unable to load products' }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
try {
const { organization } = await requireOrganizationAccess();
const body = (await request.json()) as ProductMutationPayload;
const [product] = await db
.insert(products)
.values({
organizationId: organization.id,
name: body.name,
category: body.category,
price: body.price,
description: body.description,
photoUrl: body.photoDataUrl || buildProductImageUrl(body.name)
})
.returning();
return NextResponse.json(
{
success: true,
time: new Date().toISOString(),
message: 'Product created successfully',
product: {
id: product.id,
organization_id: product.organizationId,
photo_url: product.photoUrl,
name: product.name,
description: product.description,
created_at: product.createdAt.toISOString(),
price: product.price,
category: product.category,
updated_at: product.updatedAt.toISOString()
}
},
{ status: 201 }
);
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'Unable to create product' }, { status: 500 });
}
}

View File

@@ -0,0 +1,125 @@
import { hash } from 'bcryptjs';
import { eq } from 'drizzle-orm';
import { NextRequest, NextResponse } from 'next/server';
import { memberships, users } from '@/db/schema';
import { db } from '@/lib/db';
import { AuthError } from '@/lib/auth/session';
import {
assertManageableTargetUser,
buildMembershipValues,
parseUserMutationPayload,
requireUserManagementAccess,
validateManagedOrganizations
} from '../_lib';
type Params = { params: Promise<{ id: string }> };
export async function PUT(request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { session, isSuperAdmin, manageableOrganizationIds } = await requireUserManagementAccess();
const payload = parseUserMutationPayload(await request.json());
const targetUser = await assertManageableTargetUser(id, manageableOrganizationIds, isSuperAdmin);
await validateManagedOrganizations(payload.memberships, manageableOrganizationIds, isSuperAdmin);
if (!isSuperAdmin && payload.systemRole !== 'user') {
return NextResponse.json({ message: 'Forbidden' }, { status: 403 });
}
if (!isSuperAdmin && targetUser.systemRole === 'super_admin') {
return NextResponse.json({ message: 'Forbidden' }, { status: 403 });
}
const userWithSameEmail = await db.query.users.findFirst({
where: eq(users.email, payload.email)
});
if (userWithSameEmail && userWithSameEmail.id !== id) {
return NextResponse.json(
{ message: 'An account with this email already exists' },
{ status: 409 }
);
}
const updateValues: {
name: string;
email: string;
systemRole: 'super_admin' | 'user';
activeOrganizationId: string | null;
passwordHash?: string;
} = {
name: payload.name,
email: payload.email,
systemRole: targetUser.systemRole === 'super_admin' ? 'super_admin' : 'user',
activeOrganizationId: payload.memberships[0]?.organizationId ?? null
};
if (payload.password) {
if (payload.password.length < 8) {
return NextResponse.json({ message: 'Password must be at least 8 characters' }, { status: 400 });
}
updateValues.passwordHash = await hash(payload.password, 10);
}
await db.update(users).set(updateValues).where(eq(users.id, id));
await db.delete(memberships).where(eq(memberships.userId, id));
await db.insert(memberships).values(buildMembershipValues(id, payload.memberships));
if (
session.user.id === id &&
!payload.memberships.some(
(membership) => membership.organizationId === (session.user.activeOrganizationId ?? '')
)
) {
await db
.update(users)
.set({ activeOrganizationId: payload.memberships[0]?.organizationId ?? null })
.where(eq(users.id, id));
}
return NextResponse.json({
success: true,
message: 'User updated successfully'
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to update user' }, { status: 500 });
}
}
export async function DELETE(_request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { session, isSuperAdmin, manageableOrganizationIds } = await requireUserManagementAccess();
if (session.user.id === id) {
return NextResponse.json({ message: 'You cannot delete your own account' }, { status: 400 });
}
await assertManageableTargetUser(id, manageableOrganizationIds, isSuperAdmin);
await db.delete(memberships).where(eq(memberships.userId, id));
await db.delete(users).where(eq(users.id, id));
return NextResponse.json({
success: true,
message: 'User deleted successfully'
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'Unable to delete user' }, { status: 500 });
}
}

194
src/app/api/users/_lib.ts Normal file
View File

@@ -0,0 +1,194 @@
import { eq, inArray } from 'drizzle-orm';
import { memberships, organizations, users } from '@/db/schema';
import {
getDefaultBusinessRole,
getDefaultPermissions,
isBusinessRole,
isMembershipRole,
isSystemRole
} from '@/lib/auth/rbac';
import { AuthError, requireSession } from '@/lib/auth/session';
import { db } from '@/lib/db';
export type UserMembershipInput = {
organizationId: string;
membershipRole: 'admin' | 'user';
businessRole: 'it_admin' | 'helpdesk' | 'infrastructure' | 'application' | 'auditor' | 'viewer';
};
export type UserMutationInput = {
name: string;
email: string;
password?: string;
systemRole: 'super_admin' | 'user';
memberships: UserMembershipInput[];
};
export async function requireUserManagementAccess() {
const session = await requireSession();
if (session.user.systemRole === 'super_admin') {
const allOrganizations = await db.select({ id: organizations.id }).from(organizations);
return {
session,
isSuperAdmin: true,
manageableOrganizationIds: allOrganizations.map((organization) => organization.id)
};
}
const adminMemberships = await db.query.memberships.findMany({
where: eq(memberships.userId, session.user.id)
});
const manageableOrganizationIds = adminMemberships
.filter((membership) => membership.role === 'admin')
.map((membership) => membership.organizationId);
if (!manageableOrganizationIds.length) {
throw new AuthError('Forbidden', 403);
}
return {
session,
isSuperAdmin: false,
manageableOrganizationIds
};
}
export function parseUserMutationPayload(body: unknown): UserMutationInput {
if (!body || typeof body !== 'object') {
throw new Error('Invalid user payload');
}
const payload = body as Record<string, unknown>;
const membershipsPayload = Array.isArray(payload.memberships) ? payload.memberships : [];
const name = typeof payload.name === 'string' ? payload.name.trim() : '';
const email = typeof payload.email === 'string' ? payload.email.trim().toLowerCase() : '';
const password = typeof payload.password === 'string' ? payload.password.trim() : undefined;
const systemRoleValue = typeof payload.systemRole === 'string' ? payload.systemRole : 'user';
if (!name || !email || !isSystemRole(systemRoleValue)) {
throw new Error('Invalid user payload');
}
const memberships = membershipsPayload
.map((membership) => {
if (!membership || typeof membership !== 'object') {
return null;
}
const value = membership as Record<string, unknown>;
const organizationId =
typeof value.organizationId === 'string' ? value.organizationId.trim() : '';
const membershipRole =
typeof value.membershipRole === 'string' ? value.membershipRole : '';
const businessRole =
typeof value.businessRole === 'string'
? value.businessRole
: getDefaultBusinessRole(
membershipRole === 'admin' || membershipRole === 'user' ? membershipRole : 'user'
);
if (!organizationId || !isMembershipRole(membershipRole) || !isBusinessRole(businessRole)) {
return null;
}
return {
organizationId,
membershipRole,
businessRole
};
})
.filter((membership): membership is UserMembershipInput => membership !== null);
const uniqueMemberships = [...new Map(memberships.map((membership) => [membership.organizationId, membership])).values()];
if (!uniqueMemberships.length) {
throw new Error('Select at least one organization');
}
return {
name,
email,
password,
systemRole: systemRoleValue,
memberships: uniqueMemberships
};
}
export async function validateManagedOrganizations(
targetMemberships: UserMembershipInput[],
manageableOrganizationIds: string[],
isSuperAdmin: boolean
) {
const organizationIds = targetMemberships.map((membership) => membership.organizationId);
const organizationRows = await db
.select({
id: organizations.id
})
.from(organizations)
.where(inArray(organizations.id, organizationIds));
if (organizationRows.length !== organizationIds.length) {
throw new Error('One or more organizations do not exist');
}
if (
!isSuperAdmin &&
organizationIds.some((organizationId) => !manageableOrganizationIds.includes(organizationId))
) {
throw new AuthError('Forbidden', 403);
}
}
export async function getUserMembershipRows(userId: string) {
return db.query.memberships.findMany({
where: eq(memberships.userId, userId)
});
}
export async function assertManageableTargetUser(
userId: string,
manageableOrganizationIds: string[],
isSuperAdmin: boolean
) {
const user = await db.query.users.findFirst({
where: eq(users.id, userId)
});
if (!user) {
throw new AuthError('User not found', 404);
}
if (user.systemRole === 'super_admin' && !isSuperAdmin) {
throw new AuthError('Only super admins can edit super admin users', 403);
}
if (!isSuperAdmin) {
const targetMemberships = await getUserMembershipRows(userId);
if (
!targetMemberships.length ||
targetMemberships.some(
(membership) => !manageableOrganizationIds.includes(membership.organizationId)
)
) {
throw new AuthError('Forbidden', 403);
}
}
return user;
}
export function buildMembershipValues(userId: string, targetMemberships: UserMembershipInput[]) {
return targetMemberships.map((membership) => ({
id: crypto.randomUUID(),
userId,
organizationId: membership.organizationId,
role: membership.membershipRole,
businessRole: membership.businessRole,
permissions: getDefaultPermissions(membership.membershipRole, membership.businessRole)
}));
}

292
src/app/api/users/route.ts Normal file
View File

@@ -0,0 +1,292 @@
import { hash } from 'bcryptjs';
import { asc, eq } from 'drizzle-orm';
import { NextRequest, NextResponse } from 'next/server';
import { memberships, organizations, users } from '@/db/schema';
import { db } from '@/lib/db';
import { AuthError } from '@/lib/auth/session';
import {
buildMembershipValues,
parseUserMutationPayload,
requireUserManagementAccess,
validateManagedOrganizations
} from './_lib';
type UserRow = {
userId: string;
name: string;
email: string;
systemRole: string;
activeOrganizationId: string | null;
organizationId: string;
organizationName: string;
membershipRole: string;
businessRole: string;
};
function formatUsers(rows: UserRow[]) {
const usersMap = new Map<
string,
{
id: string;
name: string;
email: string;
systemRole: 'super_admin' | 'user';
activeMembershipRole: 'admin' | 'user' | null;
activeOrganizationId: string | null;
organizations: Array<{ id: string; name: string; role: 'admin' | 'user' }>;
memberships: Array<{
organizationId: string;
organizationName: string;
membershipRole: 'admin' | 'user';
businessRole:
| 'it_admin'
| 'helpdesk'
| 'infrastructure'
| 'application'
| 'auditor'
| 'viewer';
}>;
}
>();
for (const row of rows) {
const existing = usersMap.get(row.userId);
const organization = {
id: row.organizationId,
name: row.organizationName,
role: row.membershipRole === 'admin' ? 'admin' : 'user'
} as const;
const membership = {
organizationId: row.organizationId,
organizationName: row.organizationName,
membershipRole: organization.role,
businessRole:
row.businessRole === 'it_admin' ||
row.businessRole === 'helpdesk' ||
row.businessRole === 'infrastructure' ||
row.businessRole === 'application' ||
row.businessRole === 'auditor'
? row.businessRole
: 'viewer'
} as const;
if (existing) {
if (!existing.organizations.some((item) => item.id === organization.id)) {
existing.organizations.push(organization);
}
if (!existing.memberships.some((item) => item.organizationId === membership.organizationId)) {
existing.memberships.push(membership);
}
if (row.organizationId === existing.activeOrganizationId) {
existing.activeMembershipRole = organization.role;
}
continue;
}
usersMap.set(row.userId, {
id: row.userId,
name: row.name,
email: row.email,
systemRole: row.systemRole === 'super_admin' ? 'super_admin' : 'user',
activeMembershipRole:
row.organizationId === row.activeOrganizationId ? organization.role : null,
activeOrganizationId: row.activeOrganizationId,
organizations: [organization],
memberships: [membership]
});
}
return [...usersMap.values()].map((user) => ({
...user,
organizations: user.organizations.toSorted((a, b) => a.name.localeCompare(b.name)),
memberships: user.memberships.toSorted((a, b) =>
a.organizationName.localeCompare(b.organizationName)
)
}));
}
function applyRoleFilter(usersList: ReturnType<typeof formatUsers>, roles?: string) {
if (!roles) {
return usersList;
}
const selectedRoles = roles
.split(/[.,]/)
.map((value) => value.trim())
.filter(Boolean);
return usersList.filter(
(user) =>
selectedRoles.includes(user.systemRole) ||
user.memberships.some((membership) => selectedRoles.includes(membership.membershipRole))
);
}
function sortUsers(usersList: ReturnType<typeof formatUsers>, sort?: string) {
const defaultSort = usersList.toSorted((a, b) => a.name.localeCompare(b.name));
if (!sort) {
return defaultSort;
}
try {
const sortItems = JSON.parse(sort) as Array<{ id: string; desc: boolean }>;
const [primarySort] = sortItems;
if (!primarySort) {
return defaultSort;
}
const sorted = usersList.toSorted((a, b) => {
const left =
primarySort.id === 'role'
? `${a.systemRole}:${a.activeMembershipRole ?? ''}`
: primarySort.id === 'organizations'
? a.memberships.map((membership) => membership.organizationName).join(', ')
: a[primarySort.id as 'name' | 'email'] ?? a.name;
const right =
primarySort.id === 'role'
? `${b.systemRole}:${b.activeMembershipRole ?? ''}`
: primarySort.id === 'organizations'
? b.memberships.map((membership) => membership.organizationName).join(', ')
: b[primarySort.id as 'name' | 'email'] ?? b.name;
return String(left).localeCompare(String(right));
});
return primarySort.desc ? sorted.toReversed() : sorted;
} catch {
return defaultSort;
}
}
export async function GET(request: NextRequest) {
try {
const { isSuperAdmin, manageableOrganizationIds } = await requireUserManagementAccess();
const { searchParams } = request.nextUrl;
const page = Number(searchParams.get('page') ?? 1);
const limit = Number(searchParams.get('limit') ?? 10);
const roles = searchParams.get('roles') ?? undefined;
const search = searchParams.get('search') ?? undefined;
const sort = searchParams.get('sort') ?? undefined;
const rows = await db
.select({
userId: users.id,
name: users.name,
email: users.email,
systemRole: users.systemRole,
activeOrganizationId: users.activeOrganizationId,
organizationId: memberships.organizationId,
organizationName: organizations.name,
membershipRole: memberships.role,
businessRole: memberships.businessRole
})
.from(users)
.innerJoin(memberships, eq(memberships.userId, users.id))
.innerJoin(organizations, eq(organizations.id, memberships.organizationId))
.orderBy(asc(users.name));
const scopedRows = isSuperAdmin
? rows
: rows.filter((row) => manageableOrganizationIds.includes(row.organizationId));
const searchTerm = search?.trim().toLowerCase();
const searchedRows = searchTerm
? scopedRows.filter(
(row) =>
row.name.toLowerCase().includes(searchTerm) ||
row.email.toLowerCase().includes(searchTerm)
)
: scopedRows;
const formattedUsers = formatUsers(searchedRows);
const filteredUsers = applyRoleFilter(formattedUsers, roles);
const sortedUsers = sortUsers(filteredUsers, sort);
const offset = (page - 1) * limit;
const pagedUsers = sortedUsers.slice(offset, offset + limit);
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'Users loaded successfully',
total_users: sortedUsers.length,
offset,
limit,
users: pagedUsers
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'Unable to load users' }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
try {
const { isSuperAdmin, manageableOrganizationIds } = await requireUserManagementAccess();
const payload = parseUserMutationPayload(await request.json());
if (!payload.password || payload.password.length < 8) {
return NextResponse.json(
{ message: 'Password must be at least 8 characters' },
{ status: 400 }
);
}
if (payload.systemRole === 'super_admin') {
return NextResponse.json(
{ message: 'Creating new super admin users is not supported in this form' },
{ status: 400 }
);
}
await validateManagedOrganizations(payload.memberships, manageableOrganizationIds, isSuperAdmin);
const existingUser = await db.query.users.findFirst({
where: eq(users.email, payload.email)
});
if (existingUser) {
return NextResponse.json(
{ message: 'An account with this email already exists' },
{ status: 409 }
);
}
const userId = crypto.randomUUID();
const passwordHash = await hash(payload.password, 10);
await db.insert(users).values({
id: userId,
name: payload.name,
email: payload.email,
passwordHash,
systemRole: payload.systemRole,
activeOrganizationId: payload.memberships[0]?.organizationId ?? null
});
await db.insert(memberships).values(buildMembershipValues(userId, payload.memberships));
return NextResponse.json(
{
success: true,
message: 'User created successfully'
},
{ status: 201 }
);
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to create user' }, { status: 500 });
}
}

12
src/app/auth/layout.tsx Normal file
View File

@@ -0,0 +1,12 @@
import { Metadata } from 'next';
export const metadata: Metadata = {
robots: {
index: false,
follow: false
}
};
export default function AuthLayout({ children }: { children: React.ReactNode }) {
return children;
}

13
src/app/auth/page.tsx Normal file
View File

@@ -0,0 +1,13 @@
import { Metadata } from 'next';
import { redirect } from 'next/navigation';
export const metadata: Metadata = {
robots: {
index: false,
follow: false
}
};
export default function AuthPage() {
redirect('/auth/sign-in');
}

View File

@@ -0,0 +1,11 @@
import { Metadata } from 'next';
import SignInViewPage from '@/features/auth/components/sign-in-view';
export const metadata: Metadata = {
title: 'Authentication | Sign In',
description: 'Sign In page for authentication.'
};
export default async function Page() {
return <SignInViewPage />;
}

View File

@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation';
export default function Page() {
redirect('/auth/sign-in');
}

View File

@@ -0,0 +1,26 @@
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import PageContainer from '@/components/layout/page-container';
import { approvalsQueryOptions, crmReferenceQueryOptions } from '@/features/crm/api/queries';
import { ApprovalsPage } from '@/features/crm/components/approvals-page';
import { getQueryClient } from '@/lib/query-client';
export const metadata = {
title: 'Dashboard: CRM Approvals'
};
export default function ApprovalsRoute() {
const queryClient = getQueryClient();
void queryClient.prefetchQuery(crmReferenceQueryOptions());
void queryClient.prefetchQuery(approvalsQueryOptions({ page: 1, limit: 10 }));
return (
<PageContainer
pageTitle='Approvals'
pageDescription='Pending approval list, approver level, branch, amount และ approve/reject dialog'
>
<HydrationBoundary state={dehydrate(queryClient)}>
<ApprovalsPage />
</HydrationBoundary>
</PageContainer>
);
}

View File

@@ -0,0 +1,24 @@
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import PageContainer from '@/components/layout/page-container';
import { customerByIdOptions } from '@/features/crm/api/queries';
import { CustomerDetailPage } from '@/features/crm/components/customer-detail';
import { getQueryClient } from '@/lib/query-client';
type PageProps = { params: Promise<{ id: string }> };
export default async function CustomerDetailRoute({ params }: PageProps) {
const { id } = await params;
const queryClient = getQueryClient();
void queryClient.prefetchQuery(customerByIdOptions(id));
return (
<PageContainer
pageTitle='Customer Detail'
pageDescription='Overview, contacts, shared contacts, enquiries, quotations, and activity log'
>
<HydrationBoundary state={dehydrate(queryClient)}>
<CustomerDetailPage id={id} />
</HydrationBoundary>
</PageContainer>
);
}

View File

@@ -0,0 +1,49 @@
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import type { SearchParams } from 'nuqs/server';
import PageContainer from '@/components/layout/page-container';
import { crmReferenceQueryOptions, customersQueryOptions } from '@/features/crm/api/queries';
import { CustomersTablePage } from '@/features/crm/components/customers-table';
import { getQueryClient } from '@/lib/query-client';
import { searchParamsCache } from '@/lib/searchparams';
export const metadata = {
title: 'Dashboard: CRM Customers'
};
export default async function CustomersRoute({
searchParams
}: {
searchParams: Promise<SearchParams>;
}) {
searchParamsCache.parse(await searchParams);
const page = searchParamsCache.get('page');
const perPage = searchParamsCache.get('perPage');
const search = searchParamsCache.get('name');
const status = searchParamsCache.get('customerStatus');
const branch = searchParamsCache.get('branch');
const sort = searchParamsCache.get('sort');
const queryClient = getQueryClient();
void queryClient.prefetchQuery(crmReferenceQueryOptions());
void queryClient.prefetchQuery(
customersQueryOptions({
page,
limit: perPage,
...(search && { search }),
...(status && { status }),
...(branch && { branch }),
...(sort && { sort })
})
);
return (
<PageContainer
pageTitle='Customers'
pageDescription='Customer master พร้อม status, branch, contacts และ drawer สำหรับ create/edit'
>
<HydrationBoundary state={dehydrate(queryClient)}>
<CustomersTablePage />
</HydrationBoundary>
</PageContainer>
);
}

View File

@@ -0,0 +1,24 @@
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import PageContainer from '@/components/layout/page-container';
import { enquiryByIdOptions } from '@/features/crm/api/queries';
import { EnquiryDetailPage } from '@/features/crm/components/enquiry-detail';
import { getQueryClient } from '@/lib/query-client';
type PageProps = { params: Promise<{ id: string }> };
export default async function EnquiryDetailRoute({ params }: PageProps) {
const { id } = await params;
const queryClient = getQueryClient();
void queryClient.prefetchQuery(enquiryByIdOptions(id));
return (
<PageContainer
pageTitle='Enquiry Detail'
pageDescription='Header summary, customer/contact, requirement summary, timeline, and related quotations'
>
<HydrationBoundary state={dehydrate(queryClient)}>
<EnquiryDetailPage id={id} />
</HydrationBoundary>
</PageContainer>
);
}

View File

@@ -0,0 +1,55 @@
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import type { SearchParams } from 'nuqs/server';
import PageContainer from '@/components/layout/page-container';
import { crmReferenceQueryOptions, enquiriesQueryOptions } from '@/features/crm/api/queries';
import { EnquiriesTablePage } from '@/features/crm/components/enquiries-table';
import { getQueryClient } from '@/lib/query-client';
import { searchParamsCache } from '@/lib/searchparams';
export const metadata = {
title: 'Dashboard: CRM Enquiries'
};
export default async function EnquiriesRoute({
searchParams
}: {
searchParams: Promise<SearchParams>;
}) {
searchParamsCache.parse(await searchParams);
const page = searchParamsCache.get('page');
const perPage = searchParamsCache.get('perPage');
const search = searchParamsCache.get('name');
const status = searchParamsCache.get('status');
const productType = searchParamsCache.get('productType');
const salesman = searchParamsCache.get('salesman');
const dateFrom = searchParamsCache.get('dateFrom');
const dateTo = searchParamsCache.get('dateTo');
const sort = searchParamsCache.get('sort');
const queryClient = getQueryClient();
void queryClient.prefetchQuery(crmReferenceQueryOptions());
void queryClient.prefetchQuery(
enquiriesQueryOptions({
page,
limit: perPage,
...(search && { search }),
...(status && { status }),
...(productType && { productType }),
...(salesman && { salesman }),
...(dateFrom && { dateFrom }),
...(dateTo && { dateTo }),
...(sort && { sort })
})
);
return (
<PageContainer
pageTitle='Enquiries'
pageDescription='List ของ sales opportunity พร้อม filter, date range และ convert to quotation'
>
<HydrationBoundary state={dehydrate(queryClient)}>
<EnquiriesTablePage />
</HydrationBoundary>
</PageContainer>
);
}

View File

@@ -0,0 +1,25 @@
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import PageContainer from '@/components/layout/page-container';
import { getQueryClient } from '@/lib/query-client';
import { crmDashboardQueryOptions } from '@/features/crm/api/queries';
import { CrmDashboardPage } from '@/features/crm/components/crm-dashboard';
export const metadata = {
title: 'Dashboard: CRM'
};
export default function CrmDashboardRoute() {
const queryClient = getQueryClient();
void queryClient.prefetchQuery(crmDashboardQueryOptions());
return (
<PageContainer
pageTitle='CRM Dashboard'
pageDescription='ภาพรวม workflow ของ Customer → Enquiry → Quotation → Approval → Won/Lost'
>
<HydrationBoundary state={dehydrate(queryClient)}>
<CrmDashboardPage />
</HydrationBoundary>
</PageContainer>
);
}

View File

@@ -0,0 +1,24 @@
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import PageContainer from '@/components/layout/page-container';
import { quotationByIdOptions } from '@/features/crm/api/queries';
import { QuotationDetailPage } from '@/features/crm/components/quotation-detail';
import { getQueryClient } from '@/lib/query-client';
type PageProps = { params: Promise<{ id: string }> };
export default async function QuotationDetailRoute({ params }: PageProps) {
const { id } = await params;
const queryClient = getQueryClient();
void queryClient.prefetchQuery(quotationByIdOptions(id));
return (
<PageContainer
pageTitle='Quotation Detail'
pageDescription='Header, status, revision, customer roles, item table, approval timeline, and preview panel'
>
<HydrationBoundary state={dehydrate(queryClient)}>
<QuotationDetailPage id={id} />
</HydrationBoundary>
</PageContainer>
);
}

View File

@@ -0,0 +1,57 @@
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import type { SearchParams } from 'nuqs/server';
import PageContainer from '@/components/layout/page-container';
import { crmReferenceQueryOptions, quotationsQueryOptions } from '@/features/crm/api/queries';
import { QuotationsTablePage } from '@/features/crm/components/quotations-table';
import { getQueryClient } from '@/lib/query-client';
import { searchParamsCache } from '@/lib/searchparams';
export const metadata = {
title: 'Dashboard: CRM Quotations'
};
export default async function QuotationsRoute({
searchParams
}: {
searchParams: Promise<SearchParams>;
}) {
searchParamsCache.parse(await searchParams);
const page = searchParamsCache.get('page');
const perPage = searchParamsCache.get('perPage');
const search = searchParamsCache.get('name');
const status = searchParamsCache.get('status');
const quotationType = searchParamsCache.get('quotationType');
const salesman = searchParamsCache.get('salesman');
const hot = searchParamsCache.get('hot');
const dateFrom = searchParamsCache.get('dateFrom');
const dateTo = searchParamsCache.get('dateTo');
const sort = searchParamsCache.get('sort');
const queryClient = getQueryClient();
void queryClient.prefetchQuery(crmReferenceQueryOptions());
void queryClient.prefetchQuery(
quotationsQueryOptions({
page,
limit: perPage,
...(search && { search }),
...(status && { status }),
...(quotationType && { quotationType }),
...(salesman && { salesman }),
...(hot && { hot }),
...(dateFrom && { dateFrom }),
...(dateTo && { dateTo }),
...(sort && { sort })
})
);
return (
<PageContainer
pageTitle='Quotations'
pageDescription='Quotation table with filters, hot project indicator, and kanban toggle'
>
<HydrationBoundary state={dehydrate(queryClient)}>
<QuotationsTablePage />
</HydrationBoundary>
</PageContainer>
);
}

View File

@@ -0,0 +1,21 @@
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import PageContainer from '@/components/layout/page-container';
import { crmReferenceQueryOptions } from '@/features/crm/api/queries';
import { DocumentSequencesPage } from '@/features/crm/components/settings-page';
import { getQueryClient } from '@/lib/query-client';
export default function DocumentSequencesRoute() {
const queryClient = getQueryClient();
void queryClient.prefetchQuery(crmReferenceQueryOptions());
return (
<PageContainer
pageTitle='Document Sequences'
pageDescription='Mock document sequence แยกตาม branch และ document type'
>
<HydrationBoundary state={dehydrate(queryClient)}>
<DocumentSequencesPage />
</HydrationBoundary>
</PageContainer>
);
}

View File

@@ -0,0 +1,21 @@
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import PageContainer from '@/components/layout/page-container';
import { crmReferenceQueryOptions } from '@/features/crm/api/queries';
import { MasterOptionsPage } from '@/features/crm/components/settings-page';
import { getQueryClient } from '@/lib/query-client';
export default function MasterOptionsRoute() {
const queryClient = getQueryClient();
void queryClient.prefetchQuery(crmReferenceQueryOptions());
return (
<PageContainer
pageTitle='Master Options'
pageDescription='Mock UI สำหรับ status options, product types, payment term, currency และ tax rate'
>
<HydrationBoundary state={dehydrate(queryClient)}>
<MasterOptionsPage />
</HydrationBoundary>
</PageContainer>
);
}

View File

@@ -0,0 +1,21 @@
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import PageContainer from '@/components/layout/page-container';
import { crmReferenceQueryOptions } from '@/features/crm/api/queries';
import { TemplatesPage } from '@/features/crm/components/settings-page';
import { getQueryClient } from '@/lib/query-client';
export default function TemplatesRoute() {
const queryClient = getQueryClient();
void queryClient.prefetchQuery(crmReferenceQueryOptions());
return (
<PageContainer
pageTitle='Quotation Templates'
pageDescription='Mock quotation template, version และ placeholder mappings สำหรับต่อยอด PDF preview'
>
<HydrationBoundary state={dehydrate(queryClient)}>
<TemplatesPage />
</HydrationBoundary>
</PageContainer>
);
}

View File

@@ -0,0 +1,26 @@
import type { ReactNode } from 'react';
import AppSidebar from '@/components/layout/app-sidebar';
import Header from '@/components/layout/header';
import { InfoSidebar } from '@/components/layout/info-sidebar';
import KBar from '@/components/kbar';
import { InfobarProvider } from '@/components/ui/infobar';
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
export default function DashboardLayout({ children }: { children: ReactNode }) {
return (
<KBar>
<InfobarProvider defaultOpen={false}>
<SidebarProvider defaultOpen>
<AppSidebar />
<SidebarInset>
<Header />
<div className='flex flex-1 overflow-hidden'>
<div className='flex min-w-0 flex-1 flex-col'>{children}</div>
<InfoSidebar side='right' />
</div>
</SidebarInset>
</SidebarProvider>
</InfobarProvider>
</KBar>
);
}

View File

@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation';
export default function DashboardIndexPage() {
redirect('/dashboard/crm');
}

View File

@@ -0,0 +1,47 @@
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import PageContainer from '@/components/layout/page-container';
import { getQueryClient } from '@/lib/query-client';
import { searchParamsCache } from '@/lib/searchparams';
import type { SearchParams } from 'nuqs/server';
import { exampleUsersQueryOptions } from '@/features/example-dashboard/api';
import { ExampleUserTable } from '@/features/example-dashboard/components/example-user-table';
type PageProps = {
searchParams: Promise<SearchParams>;
};
export const metadata = {
title: 'Example Dashboard: Users'
};
export default async function ExampleUsersPage(props: PageProps) {
const searchParams = await props.searchParams;
searchParamsCache.parse(searchParams);
const page = searchParamsCache.get('page');
const search = searchParamsCache.get('name');
const pageLimit = searchParamsCache.get('perPage');
const roles = searchParamsCache.get('role');
const sort = searchParamsCache.get('sort');
const filters = {
page,
limit: pageLimit,
...(search && { search }),
...(roles && { roles }),
...(sort && { sort })
};
const queryClient = getQueryClient();
void queryClient.prefetchQuery(exampleUsersQueryOptions(filters));
return (
<PageContainer
pageTitle='Users'
pageDescription='Public demo of the organization-aware user listing pattern without requiring auth.'
>
<HydrationBoundary state={dehydrate(queryClient)}>
<ExampleUserTable />
</HydrationBoundary>
</PageContainer>
);
}

View File

@@ -0,0 +1,194 @@
'use client';
import { useEffect, useState } from 'react';
import PageContainer from '@/components/layout/page-container';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { workspacesInfoContent } from '@/config/infoconfig';
import { useSession } from 'next-auth/react';
import { useRouter } from 'next/navigation';
import { toast } from 'sonner';
type OrganizationSummary = {
id: string;
name: string;
slug: string;
plan: string;
imageUrl: string | null;
role: string;
canManageUsers: boolean;
};
export default function WorkspacesPage() {
const { data: session, status, update } = useSession();
const router = useRouter();
const [workspaceName, setWorkspaceName] = useState('');
const [isCreating, setIsCreating] = useState(false);
const [organizations, setOrganizations] = useState<OrganizationSummary[]>([]);
const [isLoadingOrganizations, setIsLoadingOrganizations] = useState(false);
const canManageOrganizations = session?.user?.systemRole === 'super_admin';
useEffect(() => {
if (!canManageOrganizations) {
setOrganizations([]);
return;
}
let cancelled = false;
async function loadOrganizations() {
setIsLoadingOrganizations(true);
try {
const response = await fetch('/api/organizations');
const data = (await response.json()) as {
organizations?: OrganizationSummary[];
message?: string;
};
if (!response.ok) {
throw new Error(data.message ?? 'Unable to load organizations');
}
if (!cancelled) {
setOrganizations(data.organizations ?? []);
}
} catch (error) {
if (!cancelled) {
toast.error(error instanceof Error ? error.message : 'Unable to load organizations');
}
} finally {
if (!cancelled) {
setIsLoadingOrganizations(false);
}
}
}
void loadOrganizations();
return () => {
cancelled = true;
};
}, [canManageOrganizations, session?.user?.activeOrganizationId]);
const createWorkspace = async () => {
if (!workspaceName.trim()) {
toast.error('Workspace name is required');
return;
}
setIsCreating(true);
try {
const response = await fetch('/api/organizations', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: workspaceName })
});
if (!response.ok) {
const data = (await response.json().catch(() => null)) as { message?: string } | null;
throw new Error(data?.message ?? 'Unable to create workspace');
}
setWorkspaceName('');
await update();
router.refresh();
toast.success('Workspace created');
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Unable to create workspace');
} finally {
setIsCreating(false);
}
};
return (
<PageContainer
pageTitle='Workspaces'
pageDescription='Manage your app-owned workspaces and switch your active organization.'
infoContent={workspacesInfoContent}
isLoading={status === 'loading' || (canManageOrganizations && isLoadingOrganizations)}
access={canManageOrganizations}
accessFallback={
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
Only super admins can manage workspaces.
</div>
}
>
<div className='grid gap-6 xl:grid-cols-[1.2fr_0.8fr]'>
<Card>
<CardHeader>
<CardTitle>Your workspaces</CardTitle>
<CardDescription>
The active workspace controls organization-scoped product data and access checks.
</CardDescription>
</CardHeader>
<CardContent className='space-y-3'>
{organizations.length ? (
organizations.map((organization) => {
const isActive = organization.id === session?.user?.activeOrganizationId;
return (
<button
key={organization.id}
type='button'
onClick={() => router.push('/dashboard/workspaces/team')}
aria-label={`Open workspace ${organization.name}`}
className={`w-full rounded-lg border p-4 text-left transition ${
isActive ? 'border-primary bg-primary/5' : 'hover:bg-accent'
}`}
>
<div className='flex items-center justify-between gap-4'>
<div>
<div className='font-semibold'>{organization.name}</div>
<div className='text-muted-foreground text-sm'>
Role: {organization.role} | Plan: {organization.plan}
</div>
</div>
<div className='text-xs font-medium uppercase'>
{isActive ? 'Active' : 'Open'}
</div>
</div>
</button>
);
})
) : (
<div className='text-muted-foreground rounded-lg border border-dashed p-6 text-sm'>
No workspace found yet. Create your first workspace to unlock organization-scoped
product management.
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Create workspace</CardTitle>
<CardDescription>
This flow is limited to super admins in the organization RBAC model.
</CardDescription>
</CardHeader>
<CardContent className='space-y-4'>
<div className='space-y-2'>
<label htmlFor='workspace-name' className='text-sm font-medium'>
Workspace name
</label>
<Input
id='workspace-name'
value={workspaceName}
onChange={(event) => setWorkspaceName(event.target.value)}
placeholder='Acme Studio'
disabled={isCreating}
/>
</div>
<Button className='w-full' isLoading={isCreating} onClick={createWorkspace}>
Create workspace
</Button>
</CardContent>
</Card>
</div>
</PageContainer>
);
}

View File

@@ -0,0 +1,55 @@
'use client';
import PageContainer from '@/components/layout/page-container';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { teamInfoContent } from '@/config/infoconfig';
import { useSession } from 'next-auth/react';
export default function TeamPage() {
const { data: session, status } = useSession();
const activeOrganization = session?.user?.organizations.find(
(organization) => organization.id === session?.user?.activeOrganizationId
);
const canManageTeam =
session?.user?.systemRole === 'super_admin' ||
(session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes('users:manage')));
return (
<PageContainer
pageTitle='Team Management'
pageDescription='App-owned team and role management is scaffolded here for the Auth.js migration.'
infoContent={teamInfoContent}
isLoading={status === 'loading'}
access={!!activeOrganization && !!canManageTeam}
accessFallback={
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
Select a workspace where you are an admin to manage team settings.
</div>
}
>
<Card>
<CardHeader>
<CardTitle>{activeOrganization?.name}</CardTitle>
<CardDescription>
This placeholder keeps the route alive while the full membership CRUD flow is still
being migrated off Clerk Organizations.
</CardDescription>
</CardHeader>
<CardContent className='space-y-4 text-sm'>
<div className='rounded-lg border p-4'>
<div className='font-medium'>Current role</div>
<div className='text-muted-foreground mt-1'>
{session?.user?.activeMembershipRole ?? 'member'}
</div>
</div>
<div className='rounded-lg border border-dashed p-4 text-muted-foreground'>
Next iteration can add invites, membership edits, and permission management against the
`memberships` table introduced in this migration.
</div>
</CardContent>
</Card>
</PageContainer>
);
}

View File

@@ -0,0 +1,30 @@
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import { auth } from '@/auth';
import PageContainer from '@/components/layout/page-container';
import { assetByIdQueryOptions, assetOptionsQueryOptions } from '@/features/assets/api/queries';
import { AssetActionForm } from '@/features/assets/components/asset-action-form';
import { getQueryClient } from '@/lib/query-client';
type PageProps = {
params: Promise<{ assetId: string }>;
};
export default async function AssetAssignPage({ params }: PageProps) {
const { assetId } = await params;
const session = await auth();
const canAssign =
session?.user?.systemRole === 'super_admin' ||
session?.user?.activePermissions.includes('asset:assign');
const queryClient = getQueryClient();
void queryClient.prefetchQuery(assetByIdQueryOptions(assetId));
void queryClient.prefetchQuery(assetOptionsQueryOptions());
return (
<PageContainer pageTitle='Asset Assignment' pageDescription='Assign this asset to an employee.' access={!!canAssign}>
<HydrationBoundary state={dehydrate(queryClient)}>
<AssetActionForm action='assign' assetId={assetId} />
</HydrationBoundary>
</PageContainer>
);
}

View File

@@ -0,0 +1,35 @@
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import { auth } from '@/auth';
import PageContainer from '@/components/layout/page-container';
import { assetByIdQueryOptions, assetOptionsQueryOptions } from '@/features/assets/api/queries';
import AssetForm from '@/features/assets/components/asset-form';
import { getQueryClient } from '@/lib/query-client';
type PageProps = {
params: Promise<{ assetId: string }>;
};
export default async function EditAssetPage({ params }: PageProps) {
const { assetId } = await params;
const session = await auth();
const canUpdate =
session?.user?.systemRole === 'super_admin' ||
session?.user?.activePermissions.includes('asset:update');
const queryClient = getQueryClient();
const assetQuery = assetByIdQueryOptions(assetId);
void queryClient.prefetchQuery(assetQuery);
void queryClient.prefetchQuery(assetOptionsQueryOptions());
const assetData = await queryClient.ensureQueryData(assetQuery);
return (
<PageContainer
pageTitle='Edit Asset'
pageDescription='Update asset information.'
access={!!canUpdate}
>
<HydrationBoundary state={dehydrate(queryClient)}>
<AssetForm initialData={assetData.asset} pageTitle='Edit Asset' />
</HydrationBoundary>
</PageContainer>
);
}

View File

@@ -0,0 +1,32 @@
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import { auth } from '@/auth';
import PageContainer from '@/components/layout/page-container';
import { assetByIdQueryOptions } from '@/features/assets/api/queries';
import { AssetDetailView } from '@/features/assets/components/asset-detail-view';
import { getQueryClient } from '@/lib/query-client';
type PageProps = {
params: Promise<{ assetId: string }>;
};
export default async function AssetDetailPage({ params }: PageProps) {
const { assetId } = await params;
const session = await auth();
const canRead =
session?.user?.systemRole === 'super_admin' ||
session?.user?.activePermissions.includes('asset:read');
const queryClient = getQueryClient();
void queryClient.prefetchQuery(assetByIdQueryOptions(assetId));
return (
<PageContainer
pageTitle='Asset Detail'
pageDescription='View asset details and history.'
access={!!canRead}
>
<HydrationBoundary state={dehydrate(queryClient)}>
<AssetDetailView assetId={assetId} />
</HydrationBoundary>
</PageContainer>
);
}

View File

@@ -0,0 +1,29 @@
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import { auth } from '@/auth';
import PageContainer from '@/components/layout/page-container';
import { assetByIdQueryOptions } from '@/features/assets/api/queries';
import { AssetActionForm } from '@/features/assets/components/asset-action-form';
import { getQueryClient } from '@/lib/query-client';
type PageProps = {
params: Promise<{ assetId: string }>;
};
export default async function AssetReturnPage({ params }: PageProps) {
const { assetId } = await params;
const session = await auth();
const canReturn =
session?.user?.systemRole === 'super_admin' ||
session?.user?.activePermissions.includes('asset:return');
const queryClient = getQueryClient();
void queryClient.prefetchQuery(assetByIdQueryOptions(assetId));
return (
<PageContainer pageTitle='Asset Return' pageDescription='Return this asset back into available stock.' access={!!canReturn}>
<HydrationBoundary state={dehydrate(queryClient)}>
<AssetActionForm action='return' assetId={assetId} />
</HydrationBoundary>
</PageContainer>
);
}

View File

@@ -0,0 +1,30 @@
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import { auth } from '@/auth';
import PageContainer from '@/components/layout/page-container';
import { assetByIdQueryOptions, assetOptionsQueryOptions } from '@/features/assets/api/queries';
import { AssetActionForm } from '@/features/assets/components/asset-action-form';
import { getQueryClient } from '@/lib/query-client';
type PageProps = {
params: Promise<{ assetId: string }>;
};
export default async function AssetTransferPage({ params }: PageProps) {
const { assetId } = await params;
const session = await auth();
const canTransfer =
session?.user?.systemRole === 'super_admin' ||
session?.user?.activePermissions.includes('asset:transfer');
const queryClient = getQueryClient();
void queryClient.prefetchQuery(assetByIdQueryOptions(assetId));
void queryClient.prefetchQuery(assetOptionsQueryOptions());
return (
<PageContainer pageTitle='Asset Transfer' pageDescription='Transfer this asset to another owner or location.' access={!!canTransfer}>
<HydrationBoundary state={dehydrate(queryClient)}>
<AssetActionForm action='transfer' assetId={assetId} />
</HydrationBoundary>
</PageContainer>
);
}

View File

@@ -0,0 +1,54 @@
import { redirect } from 'next/navigation';
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import { auth } from '@/auth';
import PageContainer from '@/components/layout/page-container';
import { assetByIdQueryOptions, assetOptionsQueryOptions } from '@/features/assets/api/queries';
import { AssetActionForm } from '@/features/assets/components/asset-action-form';
import { getQueryClient } from '@/lib/query-client';
import type { SearchParams } from 'nuqs/server';
type PageProps = {
searchParams: Promise<SearchParams>;
};
export default async function AssignAssetPage({ searchParams }: PageProps) {
const params = await searchParams;
const assetId = typeof params.assetId === 'string' ? params.assetId : '';
if (assetId) {
redirect(`/dashboard/assets/${assetId}/assign`);
}
const session = await auth();
const canAssign =
session?.user?.systemRole === 'super_admin' ||
session?.user?.activePermissions.includes('asset:assign');
if (!assetId) {
return (
<PageContainer pageTitle='Assign Asset'>
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
Select an asset to assign.
</div>
</PageContainer>
);
}
const queryClient = getQueryClient();
void queryClient.prefetchQuery(assetByIdQueryOptions(assetId));
void queryClient.prefetchQuery(assetOptionsQueryOptions());
return (
<PageContainer pageTitle='Assign Asset' pageDescription='Assign an asset to an employee.'>
<HydrationBoundary state={dehydrate(queryClient)}>
{canAssign ? (
<AssetActionForm action='assign' assetId={assetId} />
) : (
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
You do not have access to assign assets.
</div>
)}
</HydrationBoundary>
</PageContainer>
);
}

View File

@@ -0,0 +1,27 @@
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import { auth } from '@/auth';
import PageContainer from '@/components/layout/page-container';
import { assetHistoryQueryOptions } from '@/features/assets/api/queries';
import { AssetHistoryView } from '@/features/assets/components/asset-history-view';
import { getQueryClient } from '@/lib/query-client';
export default async function AssetHistoryPage() {
const session = await auth();
const canRead =
session?.user?.systemRole === 'super_admin' ||
session?.user?.activePermissions.includes('asset:read');
const queryClient = getQueryClient();
void queryClient.prefetchQuery(assetHistoryQueryOptions());
return (
<PageContainer
pageTitle='Asset History'
pageDescription='Track all asset lifecycle movements.'
access={!!canRead}
>
<HydrationBoundary state={dehydrate(queryClient)}>
<AssetHistoryView />
</HydrationBoundary>
</PageContainer>
);
}

View File

@@ -0,0 +1,40 @@
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import { auth } from '@/auth';
import PageContainer from '@/components/layout/page-container';
import AssetForm from '@/features/assets/components/asset-form';
import { assetOptionsQueryOptions } from '@/features/assets/api/queries';
import { getQueryClient } from '@/lib/query-client';
export const metadata = {
title: 'Dashboard: New Asset'
};
export default async function NewAssetPage() {
const session = await auth();
const hasActiveOrganization = !!session?.user?.activeOrganizationId;
const canCreate =
session?.user?.systemRole === 'super_admin' ||
session?.user?.activePermissions.includes('asset:create');
const queryClient = getQueryClient();
if (canCreate && hasActiveOrganization) {
void queryClient.prefetchQuery(assetOptionsQueryOptions());
}
return (
<PageContainer
pageTitle='Create Asset'
pageDescription='Register a new IT asset.'
access={!!canCreate && hasActiveOrganization}
accessFallback={
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
Select an active workspace with asset-create access before creating assets.
</div>
}
>
<HydrationBoundary state={dehydrate(queryClient)}>
<AssetForm initialData={null} pageTitle='Create Asset' />
</HydrationBoundary>
</PageContainer>
);
}

View File

@@ -0,0 +1,48 @@
import Link from 'next/link';
import { SearchParams } from 'nuqs/server';
import { auth } from '@/auth';
import { Icons } from '@/components/icons';
import PageContainer from '@/components/layout/page-container';
import { buttonVariants } from '@/components/ui/button';
import AssetListingPage from '@/features/assets/components/asset-listing';
import { searchParamsCache } from '@/lib/searchparams';
import { cn } from '@/lib/utils';
export const metadata = {
title: 'Dashboard: Assets'
};
type PageProps = {
searchParams: Promise<SearchParams>;
};
export default async function AssetsPage(props: PageProps) {
const searchParams = await props.searchParams;
const session = await auth();
const canReadAssets =
session?.user?.systemRole === 'super_admin' || session?.user?.activePermissions.includes('asset:read');
searchParamsCache.parse(searchParams);
return (
<PageContainer
pageTitle='Asset List'
pageDescription='Canonical Phase 1 asset register with search, filters, sorting, and pagination.'
access={!!canReadAssets}
accessFallback={
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
You do not have access to asset management.
</div>
}
pageHeaderAction={
session?.user?.activePermissions.includes('asset:create') || session?.user?.systemRole === 'super_admin' ? (
<Link href='/dashboard/assets/new' className={cn(buttonVariants(), 'text-xs md:text-sm')}>
<Icons.add className='mr-2 h-4 w-4' /> Add Asset
</Link>
) : null
}
>
<AssetListingPage />
</PageContainer>
);
}

View File

@@ -0,0 +1,48 @@
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import { auth } from '@/auth';
import PageContainer from '@/components/layout/page-container';
import { assetByIdQueryOptions, assetOptionsQueryOptions } from '@/features/assets/api/queries';
import { AssetActionForm } from '@/features/assets/components/asset-action-form';
import { getQueryClient } from '@/lib/query-client';
import type { SearchParams } from 'nuqs/server';
type PageProps = {
searchParams: Promise<SearchParams>;
};
export default async function RepairAssetPage({ searchParams }: PageProps) {
const params = await searchParams;
const assetId = typeof params.assetId === 'string' ? params.assetId : '';
const session = await auth();
const canRepair =
session?.user?.systemRole === 'super_admin' ||
session?.user?.activePermissions.includes('asset:repair');
if (!assetId) {
return (
<PageContainer pageTitle='Repair Asset'>
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
Select an asset to repair.
</div>
</PageContainer>
);
}
const queryClient = getQueryClient();
void queryClient.prefetchQuery(assetByIdQueryOptions(assetId));
void queryClient.prefetchQuery(assetOptionsQueryOptions());
return (
<PageContainer pageTitle='Repair Asset' pageDescription='Record repair history for an asset.'>
<HydrationBoundary state={dehydrate(queryClient)}>
{canRepair ? (
<AssetActionForm action='repair' assetId={assetId} />
) : (
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
You do not have access to repair assets.
</div>
)}
</HydrationBoundary>
</PageContainer>
);
}

View File

@@ -0,0 +1,54 @@
import { redirect } from 'next/navigation';
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import { auth } from '@/auth';
import PageContainer from '@/components/layout/page-container';
import { assetByIdQueryOptions, assetOptionsQueryOptions } from '@/features/assets/api/queries';
import { AssetActionForm } from '@/features/assets/components/asset-action-form';
import { getQueryClient } from '@/lib/query-client';
import type { SearchParams } from 'nuqs/server';
type PageProps = {
searchParams: Promise<SearchParams>;
};
export default async function ReturnAssetPage({ searchParams }: PageProps) {
const params = await searchParams;
const assetId = typeof params.assetId === 'string' ? params.assetId : '';
if (assetId) {
redirect(`/dashboard/assets/${assetId}/return`);
}
const session = await auth();
const canReturn =
session?.user?.systemRole === 'super_admin' ||
session?.user?.activePermissions.includes('asset:return');
if (!assetId) {
return (
<PageContainer pageTitle='Return Asset'>
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
Select an asset to return.
</div>
</PageContainer>
);
}
const queryClient = getQueryClient();
void queryClient.prefetchQuery(assetByIdQueryOptions(assetId));
void queryClient.prefetchQuery(assetOptionsQueryOptions());
return (
<PageContainer pageTitle='Return Asset' pageDescription='Return an asset back to stock or retirement flow.'>
<HydrationBoundary state={dehydrate(queryClient)}>
{canReturn ? (
<AssetActionForm action='return' assetId={assetId} />
) : (
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
You do not have access to return assets.
</div>
)}
</HydrationBoundary>
</PageContainer>
);
}

View File

@@ -0,0 +1,33 @@
import { auth } from '@/auth';
import PageContainer from '@/components/layout/page-container';
import { MasterDataManager } from '@/features/assets/components/master-data-manager';
import { masterDataLabels } from '@/features/assets/constants';
import type { MasterDataEntity } from '@/features/assets/api/types';
type PageProps = {
params: Promise<{ entity: string }>;
};
export default async function AssetSettingsEntityPage({ params }: PageProps) {
const { entity: rawEntity } = await params;
const entity = (['sites', 'departments', 'locations', 'employees'] as const).includes(
rawEntity as MasterDataEntity
)
? (rawEntity as MasterDataEntity)
: 'sites';
const session = await auth();
const canManage =
session?.user?.systemRole === 'super_admin' ||
session?.user?.activePermissions.includes('master_data:manage');
const title = masterDataLabels[entity] ?? 'Master Data';
return (
<PageContainer
pageTitle={title}
pageDescription={`Manage ${title.toLowerCase()} for the active company.`}
access={!!canManage}
>
<MasterDataManager entity={entity} />
</PageContainer>
);
}

View File

@@ -0,0 +1,54 @@
import { redirect } from 'next/navigation';
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import { auth } from '@/auth';
import PageContainer from '@/components/layout/page-container';
import { assetByIdQueryOptions, assetOptionsQueryOptions } from '@/features/assets/api/queries';
import { AssetActionForm } from '@/features/assets/components/asset-action-form';
import { getQueryClient } from '@/lib/query-client';
import type { SearchParams } from 'nuqs/server';
type PageProps = {
searchParams: Promise<SearchParams>;
};
export default async function TransferAssetPage({ searchParams }: PageProps) {
const params = await searchParams;
const assetId = typeof params.assetId === 'string' ? params.assetId : '';
if (assetId) {
redirect(`/dashboard/assets/${assetId}/transfer`);
}
const session = await auth();
const canTransfer =
session?.user?.systemRole === 'super_admin' ||
session?.user?.activePermissions.includes('asset:transfer');
if (!assetId) {
return (
<PageContainer pageTitle='Transfer Asset'>
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
Select an asset to transfer.
</div>
</PageContainer>
);
}
const queryClient = getQueryClient();
void queryClient.prefetchQuery(assetByIdQueryOptions(assetId));
void queryClient.prefetchQuery(assetOptionsQueryOptions());
return (
<PageContainer pageTitle='Transfer Asset' pageDescription='Transfer an asset to a new owner or location.'>
<HydrationBoundary state={dehydrate(queryClient)}>
{canTransfer ? (
<AssetActionForm action='transfer' assetId={assetId} />
) : (
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
You do not have access to transfer assets.
</div>
)}
</HydrationBoundary>
</PageContainer>
);
}

View File

@@ -0,0 +1,70 @@
'use client';
import PageContainer from '@/components/layout/page-container';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Icons } from '@/components/icons';
import { billingInfoContent } from '@/config/infoconfig';
import { useSession } from 'next-auth/react';
export default function BillingPage() {
const { data: session, status } = useSession();
const activeOrganization = session?.user?.organizations.find(
(organization) => organization.id === session?.user?.activeOrganizationId
);
return (
<PageContainer
isLoading={status === 'loading'}
access={!!activeOrganization}
accessFallback={
<div className='flex min-h-[400px] items-center justify-center'>
<div className='space-y-2 text-center'>
<h2 className='text-2xl font-semibold'>No Workspace Selected</h2>
<p className='text-muted-foreground'>
Please select or create a workspace to view billing placeholders.
</p>
</div>
</div>
}
infoContent={billingInfoContent}
pageTitle='Billing & Plans'
pageDescription={`Review the current plan state for ${activeOrganization?.name ?? 'your workspace'}`}
>
<div className='space-y-6'>
<Alert>
<Icons.info className='h-4 w-4' />
<AlertDescription>
Clerk Billing has been removed from this route. Plan enforcement now reads app-owned
workspace data, and payment provider integration can be added later.
</AlertDescription>
</Alert>
<Card>
<CardHeader>
<CardTitle>Current plan</CardTitle>
<CardDescription>
This page is intentionally lightweight while billing is no longer vendor-managed.
</CardDescription>
</CardHeader>
<CardContent className='space-y-4'>
<div className='rounded-lg border p-4'>
<div className='text-muted-foreground text-sm'>Workspace</div>
<div className='text-lg font-semibold'>{activeOrganization?.name}</div>
</div>
<div className='rounded-lg border p-4'>
<div className='text-muted-foreground text-sm'>Plan</div>
<div className='text-lg font-semibold capitalize'>
{activeOrganization?.plan ?? 'free'}
</div>
</div>
<div className='text-muted-foreground rounded-lg border border-dashed p-4 text-sm'>
Add subscriptions, invoices, and provider sync in a later pass. The `organizations`
table is already the source of truth for plan gates.
</div>
</CardContent>
</Card>
</div>
</PageContainer>
);
}

View File

@@ -0,0 +1,9 @@
import ChatViewPage from '@/features/chat/components/chat-view-page';
export const metadata = {
title: 'Dashboard: Chat'
};
export default function Page() {
return <ChatViewPage />;
}

View File

@@ -0,0 +1,9 @@
import IconsViewPage from '@/features/elements/components/icons-view-page';
export const metadata = {
title: 'Example Dashboard: Icons'
};
export default function ExampleIconsPage() {
return <IconsViewPage />;
}

View File

@@ -0,0 +1,61 @@
import PageContainer from '@/components/layout/page-container';
import { auth } from '@/auth';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Icons } from '@/components/icons';
import Link from 'next/link';
export default async function ExclusivePage() {
const session = await auth();
const activeOrganizationName = session?.user?.activeOrganizationName;
const activePlan = session?.user?.activeOrganizationPlan;
return (
<PageContainer>
{activePlan !== 'pro' ? (
<div className='flex h-full items-center justify-center'>
<Alert>
<Icons.lock className='h-5 w-5 text-yellow-600' />
<AlertDescription>
<div className='mb-1 text-lg font-semibold'>Pro Plan Required</div>
<div className='text-muted-foreground'>
This page is only available to workspaces on the <span className='font-semibold'>Pro</span>{' '}
plan.
<br />
Upgrade your workspace later through the app-owned{' '}
<Link className='underline' href='/dashboard/billing'>
Billing &amp; Plans
</Link>{' '}
flow.
</div>
</AlertDescription>
</Alert>
</div>
) : (
<div className='space-y-6'>
<div>
<h1 className='flex items-center gap-2 text-3xl font-bold tracking-tight'>
<Icons.badgeCheck className='h-7 w-7 text-green-600' />
Exclusive Area
</h1>
<p className='text-muted-foreground'>
Welcome, <span className='font-semibold'>{activeOrganizationName}</span>! This page
remains gated by the workspace plan stored in your own database.
</p>
</div>
<Card>
<CardHeader>
<CardTitle>Exclusive content enabled</CardTitle>
<CardDescription>
Your active workspace currently satisfies the `pro` plan gate.
</CardDescription>
</CardHeader>
<CardContent>
<div className='text-lg'>Have a wonderful day!</div>
</CardContent>
</Card>
</div>
)}
</PageContainer>
);
}

View File

@@ -0,0 +1,17 @@
import PageContainer from '@/components/layout/page-container';
import AdvancedFormPatterns from '@/features/forms/components/advanced-form-patterns';
export const metadata = {
title: 'Dashboard: Advanced Form Patterns'
};
export default function Page() {
return (
<PageContainer
pageTitle='Advanced Form Patterns'
pageDescription='Linked fields, async validation, dynamic rows, nested objects, cross-field validation, and form-level errors.'
>
<AdvancedFormPatterns />
</PageContainer>
);
}

View File

@@ -0,0 +1,17 @@
import PageContainer from '@/components/layout/page-container';
import DemoForm from '@/components/forms/demo-form';
export const metadata = {
title: 'Dashboard: Basic Form'
};
export default function Page() {
return (
<PageContainer
pageTitle='Basic Form'
pageDescription='A comprehensive form demo with all field types.'
>
<DemoForm />
</PageContainer>
);
}

View File

@@ -0,0 +1,14 @@
import PageContainer from '@/components/layout/page-container';
import FormsShowcasePage from '@/features/forms/components/forms-showcase-page';
export const metadata = {
title: 'Dashboard: Multi-Step Form'
};
export default function Page() {
return (
<PageContainer pageTitle='Multi-Step Form' pageDescription='Multi-step wizard form pattern.'>
<FormsShowcasePage />
</PageContainer>
);
}

View File

@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation';
export default function Page() {
redirect('/dashboard/forms/basic');
}

View File

@@ -0,0 +1,17 @@
import PageContainer from '@/components/layout/page-container';
import SheetFormDemo from '@/features/forms/components/sheet-form-demo';
export const metadata = {
title: 'Dashboard: Sheet Form'
};
export default function Page() {
return (
<PageContainer
pageTitle='Sheet & Dialog Forms'
pageDescription='Form patterns inside sheets and dialogs with external submit buttons.'
>
<SheetFormDemo />
</PageContainer>
);
}

View File

@@ -0,0 +1,9 @@
import KanbanViewPage from '@/features/kanban/components/kanban-view-page';
export const metadata = {
title: 'Dashboard : Kanban view'
};
export default function page() {
return <KanbanViewPage />;
}

View File

@@ -0,0 +1,14 @@
import { ExampleDashboardShell } from '@/features/example-dashboard/components/example-shell';
export const metadata = {
title: 'Example Dashboard',
description: 'Public showcase routes for the template starter.'
};
export default function ExampleDashboardLayout({
children
}: {
children: React.ReactNode;
}) {
return <ExampleDashboardShell>{children}</ExampleDashboardShell>;
}

View File

@@ -0,0 +1,9 @@
import NotificationsPage from '@/features/notifications/components/notifications-page';
export const metadata = {
title: 'Dashboard: Notifications'
};
export default function Page() {
return <NotificationsPage />;
}

View File

@@ -0,0 +1,3 @@
export default function Default() {
return null;
}

View File

@@ -0,0 +1,14 @@
'use client';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Icons } from '@/components/icons';
export default function AreaStatsError({ error }: { error: Error }) {
return (
<Alert variant='destructive'>
<Icons.alertCircle className='h-4 w-4' />
<AlertTitle>Error</AlertTitle>
<AlertDescription>Failed to load area statistics: {error.message}</AlertDescription>
</Alert>
);
}

View File

@@ -0,0 +1,5 @@
import { AreaGraphSkeleton } from '@/features/overview/components/area-graph-skeleton';
export default function Loading() {
return <AreaGraphSkeleton />;
}

View File

@@ -0,0 +1,7 @@
import { delay } from '@/constants/mock-api';
import { AreaGraph } from '@/features/overview/components/area-graph';
export default async function AreaStats() {
await delay(2000);
return <AreaGraph />;
}

View File

@@ -0,0 +1,3 @@
export default function Default() {
return null;
}

View File

@@ -0,0 +1,60 @@
'use client';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Icons } from '@/components/icons';
import { useRouter } from 'next/navigation';
import { useEffect, useTransition } from 'react';
import * as Sentry from '@sentry/nextjs';
interface StatsErrorProps {
error: Error;
reset: () => void; // Add reset function from error boundary
}
export default function StatsError({ error, reset }: StatsErrorProps) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
useEffect(() => {
Sentry.captureException(error);
}, [error]);
// the reload fn ensures the refresh is deffered until the next render phase allowing react to handle any pending states before processing
const reload = () => {
startTransition(() => {
router.refresh();
reset();
});
};
return (
<Card className='border-red-500'>
<CardHeader className='flex flex-col items-stretch space-y-0 border-b p-0 sm:flex-row'>
<div className='flex flex-1 flex-col justify-center gap-1 px-6 py-5 sm:py-6'>
<Alert variant='destructive' className='border-none'>
<Icons.alertCircle className='h-4 w-4' />
<AlertTitle>Error</AlertTitle>
<AlertDescription className='mt-2'>
Failed to load statistics: {error.message}
</AlertDescription>
</Alert>
</div>
</CardHeader>
<CardContent className='flex h-[316px] items-center justify-center p-6'>
<div className='text-center'>
<p className='text-muted-foreground mb-4 text-sm'>
Unable to display statistics at this time
</p>
<Button
onClick={() => reload()}
variant='outline'
className='min-w-[120px]'
disabled={isPending}
>
Try again
</Button>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,5 @@
import { BarGraphSkeleton } from '@/features/overview/components/bar-graph-skeleton';
export default function Loading() {
return <BarGraphSkeleton />;
}

View File

@@ -0,0 +1,8 @@
import { delay } from '@/constants/mock-api';
import { BarGraph } from '@/features/overview/components/bar-graph';
export default async function BarStats() {
await delay(1000);
return <BarGraph />;
}

View File

@@ -0,0 +1,3 @@
export default function Default() {
return null;
}

View File

@@ -0,0 +1,14 @@
'use client';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Icons } from '@/components/icons';
export default function PieStatsError({ error }: { error: Error }) {
return (
<Alert variant='destructive'>
<Icons.alertCircle className='h-4 w-4' />
<AlertTitle>Error</AlertTitle>
<AlertDescription>Failed to load pie statistics: {error.message}</AlertDescription>
</Alert>
);
}

View File

@@ -0,0 +1,5 @@
import { PieGraphSkeleton } from '@/features/overview/components/pie-graph-skeleton';
export default function Loading() {
return <PieGraphSkeleton />;
}

View File

@@ -0,0 +1,7 @@
import { delay } from '@/constants/mock-api';
import { PieGraph } from '@/features/overview/components/pie-graph';
export default async function Stats() {
await delay(1000);
return <PieGraph />;
}

View File

@@ -0,0 +1,3 @@
export default function Default() {
return null;
}

View File

@@ -0,0 +1,14 @@
'use client';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Icons } from '@/components/icons';
export default function SalesError({ error }: { error: Error }) {
return (
<Alert variant='destructive'>
<Icons.alertCircle className='h-4 w-4' />
<AlertTitle>Error</AlertTitle>
<AlertDescription>Failed to load sales data: {error.message}</AlertDescription>
</Alert>
);
}

View File

@@ -0,0 +1,6 @@
import { RecentSalesSkeleton } from '@/features/overview/components/recent-sales-skeleton';
import React from 'react';
export default function Loading() {
return <RecentSalesSkeleton />;
}

View File

@@ -0,0 +1,7 @@
import { delay } from '@/constants/mock-api';
import { RecentSales } from '@/features/overview/components/recent-sales';
export default async function Sales() {
await delay(3000);
return <RecentSales />;
}

View File

@@ -0,0 +1,14 @@
'use client';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Icons } from '@/components/icons';
export default function OverviewError({ error }: { error: Error }) {
return (
<Alert variant='destructive'>
<Icons.alertCircle className='h-4 w-4' />
<AlertTitle>Error</AlertTitle>
<AlertDescription>Failed to load statistics: {error.message}</AlertDescription>
</Alert>
);
}

View File

@@ -0,0 +1,126 @@
import PageContainer from '@/components/layout/page-container';
import { Badge } from '@/components/ui/badge';
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardAction,
CardFooter
} from '@/components/ui/card';
import { Icons } from '@/components/icons';
import React from 'react';
export default function OverViewLayout({
sales,
pie_stats,
bar_stats,
area_stats
}: {
sales: React.ReactNode;
pie_stats: React.ReactNode;
bar_stats: React.ReactNode;
area_stats: React.ReactNode;
}) {
return (
<PageContainer>
<div className='flex flex-1 flex-col space-y-2'>
<div className='flex items-center justify-between'>
<h2 className='text-2xl font-bold tracking-tight'>Hi, Welcome back 👋</h2>
</div>
<div className='*:data-[slot=card]:from-primary/5 *:data-[slot=card]:to-card dark:*:data-[slot=card]:bg-card grid grid-cols-1 gap-4 *:data-[slot=card]:bg-gradient-to-t *:data-[slot=card]:shadow-xs md:grid-cols-2 lg:grid-cols-4'>
<Card className='@container/card'>
<CardHeader>
<CardDescription>Total Revenue</CardDescription>
<CardTitle className='text-2xl font-semibold tabular-nums @[250px]/card:text-3xl'>
$1,250.00
</CardTitle>
<CardAction>
<Badge variant='outline'>
<Icons.trendingUp />
+12.5%
</Badge>
</CardAction>
</CardHeader>
<CardFooter className='flex-col items-start gap-1.5 text-sm'>
<div className='line-clamp-1 flex gap-2 font-medium'>
Trending up this month <Icons.trendingUp className='size-4' />
</div>
<div className='text-muted-foreground'>Visitors for the last 6 months</div>
</CardFooter>
</Card>
<Card className='@container/card'>
<CardHeader>
<CardDescription>New Customers</CardDescription>
<CardTitle className='text-2xl font-semibold tabular-nums @[250px]/card:text-3xl'>
1,234
</CardTitle>
<CardAction>
<Badge variant='outline'>
<Icons.trendingDown />
-20%
</Badge>
</CardAction>
</CardHeader>
<CardFooter className='flex-col items-start gap-1.5 text-sm'>
<div className='line-clamp-1 flex gap-2 font-medium'>
Down 20% this period <Icons.trendingDown className='size-4' />
</div>
<div className='text-muted-foreground'>Acquisition needs attention</div>
</CardFooter>
</Card>
<Card className='@container/card'>
<CardHeader>
<CardDescription>Active Accounts</CardDescription>
<CardTitle className='text-2xl font-semibold tabular-nums @[250px]/card:text-3xl'>
45,678
</CardTitle>
<CardAction>
<Badge variant='outline'>
<Icons.trendingUp />
+12.5%
</Badge>
</CardAction>
</CardHeader>
<CardFooter className='flex-col items-start gap-1.5 text-sm'>
<div className='line-clamp-1 flex gap-2 font-medium'>
Strong user retention <Icons.trendingUp className='size-4' />
</div>
<div className='text-muted-foreground'>Engagement exceed targets</div>
</CardFooter>
</Card>
<Card className='@container/card'>
<CardHeader>
<CardDescription>Growth Rate</CardDescription>
<CardTitle className='text-2xl font-semibold tabular-nums @[250px]/card:text-3xl'>
4.5%
</CardTitle>
<CardAction>
<Badge variant='outline'>
<Icons.trendingUp />
+4.5%
</Badge>
</CardAction>
</CardHeader>
<CardFooter className='flex-col items-start gap-1.5 text-sm'>
<div className='line-clamp-1 flex gap-2 font-medium'>
Steady performance increase <Icons.trendingUp className='size-4' />
</div>
<div className='text-muted-foreground'>Meets growth projections</div>
</CardFooter>
</Card>
</div>
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-7'>
<div className='col-span-4'>{bar_stats}</div>
<div className='col-span-4 md:col-span-3'>
{/* sales arallel routes */}
{sales}
</div>
<div className='col-span-4'>{area_stats}</div>
<div className='col-span-4 min-h-0 md:col-span-3'>{pie_stats}</div>
</div>
</div>
</PageContainer>
);
}

View File

@@ -0,0 +1,9 @@
import OverViewPage from '@/features/overview/components/overview';
export const metadata = {
title: 'Example Dashboard: Overview'
};
export default function ExampleOverviewPage() {
return <OverViewPage />;
}

View File

@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation';
export default function ExampleDashboardIndexPage() {
redirect('/example/dashboard/overview');
}

View File

@@ -0,0 +1,30 @@
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import { getQueryClient } from '@/lib/query-client';
import { productByIdOptions } from '@/features/products/api/queries';
import PageContainer from '@/components/layout/page-container';
import ProductViewPage from '@/features/products/components/product-view-page';
export const metadata = {
title: 'Dashboard : Product View'
};
type PageProps = { params: Promise<{ productId: string }> };
export default async function Page(props: PageProps) {
const params = await props.params;
const queryClient = getQueryClient();
if (params.productId !== 'new') {
void queryClient.prefetchQuery(productByIdOptions(Number(params.productId)));
}
return (
<PageContainer>
<div className='flex-1 space-y-4'>
<HydrationBoundary state={dehydrate(queryClient)}>
<ProductViewPage productId={params.productId} />
</HydrationBoundary>
</div>
</PageContainer>
);
}

View File

@@ -0,0 +1,47 @@
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import PageContainer from '@/components/layout/page-container';
import { getQueryClient } from '@/lib/query-client';
import { searchParamsCache } from '@/lib/searchparams';
import type { SearchParams } from 'nuqs/server';
import { exampleProductsQueryOptions } from '@/features/example-dashboard/api';
import { ExampleProductTable } from '@/features/example-dashboard/components/example-product-table';
type PageProps = {
searchParams: Promise<SearchParams>;
};
export const metadata = {
title: 'Example Dashboard: Products'
};
export default async function ExampleProductsPage(props: PageProps) {
const searchParams = await props.searchParams;
searchParamsCache.parse(searchParams);
const page = searchParamsCache.get('page');
const search = searchParamsCache.get('name');
const pageLimit = searchParamsCache.get('perPage');
const categories = searchParamsCache.get('category');
const sort = searchParamsCache.get('sort');
const filters = {
page,
limit: pageLimit,
...(search && { search }),
...(categories && { categories }),
...(sort && { sort })
};
const queryClient = getQueryClient();
void queryClient.prefetchQuery(exampleProductsQueryOptions(filters));
return (
<PageContainer
pageTitle='Products'
pageDescription='Public demo of the table + filter + React Query pattern used by the template.'
>
<HydrationBoundary state={dehydrate(queryClient)}>
<ExampleProductTable />
</HydrationBoundary>
</PageContainer>
);
}

View File

@@ -0,0 +1,9 @@
import ProfileViewPage from '@/features/profile/components/profile-view-page';
export const metadata = {
title: 'Dashboard : Profile'
};
export default async function Page() {
return <ProfileViewPage />;
}

View File

@@ -0,0 +1,29 @@
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import { Suspense } from 'react';
import PageContainer from '@/components/layout/page-container';
import { getQueryClient } from '@/lib/query-client';
import { pokemonOptions } from '@/features/react-query-demo/api/queries';
import { PokemonInfo } from '@/features/react-query-demo/components/pokemon-info';
import { PokemonSkeleton } from '@/features/react-query-demo/components/pokemon-skeleton';
export const metadata = {
title: 'Example Dashboard: React Query'
};
export default function ExampleReactQueryPage() {
const queryClient = getQueryClient();
void queryClient.prefetchQuery(pokemonOptions(25));
return (
<PageContainer
pageTitle='React Query'
pageDescription='Server prefetch + hydration + suspense query pattern in a public example route.'
>
<HydrationBoundary state={dehydrate(queryClient)}>
<Suspense fallback={<PokemonSkeleton />}>
<PokemonInfo />
</Suspense>
</HydrationBoundary>
</PageContainer>
);
}

View File

@@ -0,0 +1,47 @@
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import PageContainer from '@/components/layout/page-container';
import { getQueryClient } from '@/lib/query-client';
import { searchParamsCache } from '@/lib/searchparams';
import type { SearchParams } from 'nuqs/server';
import { exampleUsersQueryOptions } from '@/features/example-dashboard/api';
import { ExampleUserTable } from '@/features/example-dashboard/components/example-user-table';
type PageProps = {
searchParams: Promise<SearchParams>;
};
export const metadata = {
title: 'Example Dashboard: Users'
};
export default async function ExampleUsersPage(props: PageProps) {
const searchParams = await props.searchParams;
searchParamsCache.parse(searchParams);
const page = searchParamsCache.get('page');
const search = searchParamsCache.get('name');
const pageLimit = searchParamsCache.get('perPage');
const roles = searchParamsCache.get('role');
const sort = searchParamsCache.get('sort');
const filters = {
page,
limit: pageLimit,
...(search && { search }),
...(roles && { roles }),
...(sort && { sort })
};
const queryClient = getQueryClient();
void queryClient.prefetchQuery(exampleUsersQueryOptions(filters));
return (
<PageContainer
pageTitle='Users'
pageDescription='Public demo of the organization-aware user listing pattern without requiring auth.'
>
<HydrationBoundary state={dehydrate(queryClient)}>
<ExampleUserTable />
</HydrationBoundary>
</PageContainer>
);
}

View File

@@ -0,0 +1,194 @@
'use client';
import { useEffect, useState } from 'react';
import PageContainer from '@/components/layout/page-container';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { workspacesInfoContent } from '@/config/infoconfig';
import { useSession } from 'next-auth/react';
import { useRouter } from 'next/navigation';
import { toast } from 'sonner';
type OrganizationSummary = {
id: string;
name: string;
slug: string;
plan: string;
imageUrl: string | null;
role: string;
canManageUsers: boolean;
};
export default function WorkspacesPage() {
const { data: session, status, update } = useSession();
const router = useRouter();
const [workspaceName, setWorkspaceName] = useState('');
const [isCreating, setIsCreating] = useState(false);
const [organizations, setOrganizations] = useState<OrganizationSummary[]>([]);
const [isLoadingOrganizations, setIsLoadingOrganizations] = useState(false);
const canManageOrganizations = session?.user?.systemRole === 'super_admin';
useEffect(() => {
if (!canManageOrganizations) {
setOrganizations([]);
return;
}
let cancelled = false;
async function loadOrganizations() {
setIsLoadingOrganizations(true);
try {
const response = await fetch('/api/organizations');
const data = (await response.json()) as {
organizations?: OrganizationSummary[];
message?: string;
};
if (!response.ok) {
throw new Error(data.message ?? 'Unable to load organizations');
}
if (!cancelled) {
setOrganizations(data.organizations ?? []);
}
} catch (error) {
if (!cancelled) {
toast.error(error instanceof Error ? error.message : 'Unable to load organizations');
}
} finally {
if (!cancelled) {
setIsLoadingOrganizations(false);
}
}
}
void loadOrganizations();
return () => {
cancelled = true;
};
}, [canManageOrganizations, session?.user?.activeOrganizationId]);
const createWorkspace = async () => {
if (!workspaceName.trim()) {
toast.error('Workspace name is required');
return;
}
setIsCreating(true);
try {
const response = await fetch('/api/organizations', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: workspaceName })
});
if (!response.ok) {
const data = (await response.json().catch(() => null)) as { message?: string } | null;
throw new Error(data?.message ?? 'Unable to create workspace');
}
setWorkspaceName('');
await update();
router.refresh();
toast.success('Workspace created');
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Unable to create workspace');
} finally {
setIsCreating(false);
}
};
return (
<PageContainer
pageTitle='Workspaces'
pageDescription='Manage your app-owned workspaces and switch your active organization.'
infoContent={workspacesInfoContent}
isLoading={status === 'loading' || (canManageOrganizations && isLoadingOrganizations)}
access={canManageOrganizations}
accessFallback={
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
Only super admins can manage workspaces.
</div>
}
>
<div className='grid gap-6 xl:grid-cols-[1.2fr_0.8fr]'>
<Card>
<CardHeader>
<CardTitle>Your workspaces</CardTitle>
<CardDescription>
The active workspace controls organization-scoped product data and access checks.
</CardDescription>
</CardHeader>
<CardContent className='space-y-3'>
{organizations.length ? (
organizations.map((organization) => {
const isActive = organization.id === session?.user?.activeOrganizationId;
return (
<button
key={organization.id}
type='button'
onClick={() => router.push('/dashboard/workspaces/team')}
aria-label={`Open workspace ${organization.name}`}
className={`w-full rounded-lg border p-4 text-left transition ${
isActive ? 'border-primary bg-primary/5' : 'hover:bg-accent'
}`}
>
<div className='flex items-center justify-between gap-4'>
<div>
<div className='font-semibold'>{organization.name}</div>
<div className='text-muted-foreground text-sm'>
Role: {organization.role} | Plan: {organization.plan}
</div>
</div>
<div className='text-xs font-medium uppercase'>
{isActive ? 'Active' : 'Open'}
</div>
</div>
</button>
);
})
) : (
<div className='text-muted-foreground rounded-lg border border-dashed p-6 text-sm'>
No workspace found yet. Create your first workspace to unlock organization-scoped
product management.
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Create workspace</CardTitle>
<CardDescription>
This flow is limited to super admins in the organization RBAC model.
</CardDescription>
</CardHeader>
<CardContent className='space-y-4'>
<div className='space-y-2'>
<label htmlFor='workspace-name' className='text-sm font-medium'>
Workspace name
</label>
<Input
id='workspace-name'
value={workspaceName}
onChange={(event) => setWorkspaceName(event.target.value)}
placeholder='Acme Studio'
disabled={isCreating}
/>
</div>
<Button className='w-full' isLoading={isCreating} onClick={createWorkspace}>
Create workspace
</Button>
</CardContent>
</Card>
</div>
</PageContainer>
);
}

View File

@@ -0,0 +1,55 @@
'use client';
import PageContainer from '@/components/layout/page-container';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { teamInfoContent } from '@/config/infoconfig';
import { useSession } from 'next-auth/react';
export default function TeamPage() {
const { data: session, status } = useSession();
const activeOrganization = session?.user?.organizations.find(
(organization) => organization.id === session?.user?.activeOrganizationId
);
const canManageTeam =
session?.user?.systemRole === 'super_admin' ||
(session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes('users:manage')));
return (
<PageContainer
pageTitle='Team Management'
pageDescription='App-owned team and role management is scaffolded here for the Auth.js migration.'
infoContent={teamInfoContent}
isLoading={status === 'loading'}
access={!!activeOrganization && !!canManageTeam}
accessFallback={
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
Select a workspace where you are an admin to manage team settings.
</div>
}
>
<Card>
<CardHeader>
<CardTitle>{activeOrganization?.name}</CardTitle>
<CardDescription>
This placeholder keeps the route alive while the full membership CRUD flow is still
being migrated off Clerk Organizations.
</CardDescription>
</CardHeader>
<CardContent className='space-y-4 text-sm'>
<div className='rounded-lg border p-4'>
<div className='font-medium'>Current role</div>
<div className='text-muted-foreground mt-1'>
{session?.user?.activeMembershipRole ?? 'member'}
</div>
</div>
<div className='rounded-lg border border-dashed p-4 text-muted-foreground'>
Next iteration can add invites, membership edits, and permission management against the
`memberships` table introduced in this migration.
</div>
</CardContent>
</Card>
</PageContainer>
);
}

BIN
src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

23
src/app/global-error.tsx Normal file
View File

@@ -0,0 +1,23 @@
'use client';
import * as Sentry from '@sentry/nextjs';
import NextError from 'next/error';
import { useEffect } from 'react';
export default function GlobalError({ error }: { error: Error & { digest?: string } }) {
useEffect(() => {
Sentry.captureException(error);
}, [error]);
return (
<html lang='en'>
<body>
{/* `NextError` is the default Next.js error page component. Its type
definition requires a `statusCode` prop. However, since the App Router
does not expose status codes for errors, we simply pass 0 to render a
generic error message. */}
<NextError statusCode={0} />
</body>
</html>
);
}

Some files were not shown because too many files have changed in this diff Show More