init
This commit is contained in:
80
src/app/about/page.tsx
Normal file
80
src/app/about/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
31
src/app/api/assets/[id]/assign/route.ts
Normal file
31
src/app/api/assets/[id]/assign/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
27
src/app/api/assets/[id]/movements/route.ts
Normal file
27
src/app/api/assets/[id]/movements/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
31
src/app/api/assets/[id]/repair/route.ts
Normal file
31
src/app/api/assets/[id]/repair/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
31
src/app/api/assets/[id]/return/route.ts
Normal file
31
src/app/api/assets/[id]/return/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
88
src/app/api/assets/[id]/route.ts
Normal file
88
src/app/api/assets/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
31
src/app/api/assets/[id]/transfer/route.ts
Normal file
31
src/app/api/assets/[id]/transfer/route.ts
Normal 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
926
src/app/api/assets/_lib.ts
Normal 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
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
65
src/app/api/assets/dashboard/summary/route.ts
Normal file
65
src/app/api/assets/dashboard/summary/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
24
src/app/api/assets/history/route.ts
Normal file
24
src/app/api/assets/history/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
19
src/app/api/assets/options/route.ts
Normal file
19
src/app/api/assets/options/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
70
src/app/api/assets/route.ts
Normal file
70
src/app/api/assets/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
3
src/app/api/auth/[...nextauth]/route.ts
Normal file
3
src/app/api/auth/[...nextauth]/route.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { handlers } from '@/auth';
|
||||
|
||||
export const { GET, POST } = handlers;
|
||||
10
src/app/api/auth/register/route.ts
Normal file
10
src/app/api/auth/register/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
79
src/app/api/example/products/route.ts
Normal file
79
src/app/api/example/products/route.ts
Normal 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
|
||||
});
|
||||
}
|
||||
86
src/app/api/example/users/route.ts
Normal file
86
src/app/api/example/users/route.ts
Normal 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
|
||||
});
|
||||
}
|
||||
58
src/app/api/master-data/[entity]/[id]/route.ts
Normal file
58
src/app/api/master-data/[entity]/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
62
src/app/api/master-data/[entity]/route.ts
Normal file
62
src/app/api/master-data/[entity]/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
275
src/app/api/master-data/_lib.ts
Normal file
275
src/app/api/master-data/_lib.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
51
src/app/api/organizations/active/route.ts
Normal file
51
src/app/api/organizations/active/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
132
src/app/api/organizations/route.ts
Normal file
132
src/app/api/organizations/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
128
src/app/api/products/[id]/route.ts
Normal file
128
src/app/api/products/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
146
src/app/api/products/route.ts
Normal file
146
src/app/api/products/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
125
src/app/api/users/[id]/route.ts
Normal file
125
src/app/api/users/[id]/route.ts
Normal 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
194
src/app/api/users/_lib.ts
Normal 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
292
src/app/api/users/route.ts
Normal 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
12
src/app/auth/layout.tsx
Normal 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
13
src/app/auth/page.tsx
Normal 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');
|
||||
}
|
||||
11
src/app/auth/sign-in/[[...sign-in]]/page.tsx
Normal file
11
src/app/auth/sign-in/[[...sign-in]]/page.tsx
Normal 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 />;
|
||||
}
|
||||
5
src/app/auth/sign-up/[[...sign-up]]/page.tsx
Normal file
5
src/app/auth/sign-up/[[...sign-up]]/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function Page() {
|
||||
redirect('/auth/sign-in');
|
||||
}
|
||||
26
src/app/dashboard/crm/approvals/page.tsx
Normal file
26
src/app/dashboard/crm/approvals/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
24
src/app/dashboard/crm/customers/[id]/page.tsx
Normal file
24
src/app/dashboard/crm/customers/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
49
src/app/dashboard/crm/customers/page.tsx
Normal file
49
src/app/dashboard/crm/customers/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
24
src/app/dashboard/crm/enquiries/[id]/page.tsx
Normal file
24
src/app/dashboard/crm/enquiries/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
55
src/app/dashboard/crm/enquiries/page.tsx
Normal file
55
src/app/dashboard/crm/enquiries/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
25
src/app/dashboard/crm/page.tsx
Normal file
25
src/app/dashboard/crm/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
24
src/app/dashboard/crm/quotations/[id]/page.tsx
Normal file
24
src/app/dashboard/crm/quotations/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
57
src/app/dashboard/crm/quotations/page.tsx
Normal file
57
src/app/dashboard/crm/quotations/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
21
src/app/dashboard/crm/settings/document-sequences/page.tsx
Normal file
21
src/app/dashboard/crm/settings/document-sequences/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
21
src/app/dashboard/crm/settings/master-options/page.tsx
Normal file
21
src/app/dashboard/crm/settings/master-options/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
21
src/app/dashboard/crm/settings/templates/page.tsx
Normal file
21
src/app/dashboard/crm/settings/templates/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
src/app/dashboard/layout.tsx
Normal file
26
src/app/dashboard/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
5
src/app/dashboard/page.tsx
Normal file
5
src/app/dashboard/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function DashboardIndexPage() {
|
||||
redirect('/dashboard/crm');
|
||||
}
|
||||
47
src/app/dashboard/users/page.tsx
Normal file
47
src/app/dashboard/users/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
194
src/app/dashboard/workspaces/page.tsx
Normal file
194
src/app/dashboard/workspaces/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
55
src/app/dashboard/workspaces/team/[[...rest]]/page.tsx
Normal file
55
src/app/dashboard/workspaces/team/[[...rest]]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
src/app/example/dashboard/assets/[assetId]/assign/page.tsx
Normal file
30
src/app/example/dashboard/assets/[assetId]/assign/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
35
src/app/example/dashboard/assets/[assetId]/edit/page.tsx
Normal file
35
src/app/example/dashboard/assets/[assetId]/edit/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
32
src/app/example/dashboard/assets/[assetId]/page.tsx
Normal file
32
src/app/example/dashboard/assets/[assetId]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
src/app/example/dashboard/assets/[assetId]/return/page.tsx
Normal file
29
src/app/example/dashboard/assets/[assetId]/return/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
src/app/example/dashboard/assets/[assetId]/transfer/page.tsx
Normal file
30
src/app/example/dashboard/assets/[assetId]/transfer/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
54
src/app/example/dashboard/assets/assign/page.tsx
Normal file
54
src/app/example/dashboard/assets/assign/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
27
src/app/example/dashboard/assets/history/page.tsx
Normal file
27
src/app/example/dashboard/assets/history/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
40
src/app/example/dashboard/assets/new/page.tsx
Normal file
40
src/app/example/dashboard/assets/new/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
48
src/app/example/dashboard/assets/page.tsx
Normal file
48
src/app/example/dashboard/assets/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
48
src/app/example/dashboard/assets/repair/page.tsx
Normal file
48
src/app/example/dashboard/assets/repair/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
54
src/app/example/dashboard/assets/return/page.tsx
Normal file
54
src/app/example/dashboard/assets/return/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
src/app/example/dashboard/assets/settings/[entity]/page.tsx
Normal file
33
src/app/example/dashboard/assets/settings/[entity]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
54
src/app/example/dashboard/assets/transfer/page.tsx
Normal file
54
src/app/example/dashboard/assets/transfer/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
70
src/app/example/dashboard/billing/page.tsx
Normal file
70
src/app/example/dashboard/billing/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
9
src/app/example/dashboard/chat/page.tsx
Normal file
9
src/app/example/dashboard/chat/page.tsx
Normal 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 />;
|
||||
}
|
||||
9
src/app/example/dashboard/elements/icons/page.tsx
Normal file
9
src/app/example/dashboard/elements/icons/page.tsx
Normal 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 />;
|
||||
}
|
||||
61
src/app/example/dashboard/exclusive/page.tsx
Normal file
61
src/app/example/dashboard/exclusive/page.tsx
Normal 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 & 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>
|
||||
);
|
||||
}
|
||||
17
src/app/example/dashboard/forms/advanced/page.tsx
Normal file
17
src/app/example/dashboard/forms/advanced/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
src/app/example/dashboard/forms/basic/page.tsx
Normal file
17
src/app/example/dashboard/forms/basic/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
14
src/app/example/dashboard/forms/multi-step/page.tsx
Normal file
14
src/app/example/dashboard/forms/multi-step/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
5
src/app/example/dashboard/forms/page.tsx
Normal file
5
src/app/example/dashboard/forms/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function Page() {
|
||||
redirect('/dashboard/forms/basic');
|
||||
}
|
||||
17
src/app/example/dashboard/forms/sheet-form/page.tsx
Normal file
17
src/app/example/dashboard/forms/sheet-form/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
9
src/app/example/dashboard/kanban/page.tsx
Normal file
9
src/app/example/dashboard/kanban/page.tsx
Normal 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 />;
|
||||
}
|
||||
14
src/app/example/dashboard/layout.tsx
Normal file
14
src/app/example/dashboard/layout.tsx
Normal 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>;
|
||||
}
|
||||
9
src/app/example/dashboard/notifications/page.tsx
Normal file
9
src/app/example/dashboard/notifications/page.tsx
Normal 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 />;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export default function Default() {
|
||||
return null;
|
||||
}
|
||||
14
src/app/example/dashboard/overview/@area_stats/error.tsx
Normal file
14
src/app/example/dashboard/overview/@area_stats/error.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { AreaGraphSkeleton } from '@/features/overview/components/area-graph-skeleton';
|
||||
|
||||
export default function Loading() {
|
||||
return <AreaGraphSkeleton />;
|
||||
}
|
||||
7
src/app/example/dashboard/overview/@area_stats/page.tsx
Normal file
7
src/app/example/dashboard/overview/@area_stats/page.tsx
Normal 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 />;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export default function Default() {
|
||||
return null;
|
||||
}
|
||||
60
src/app/example/dashboard/overview/@bar_stats/error.tsx
Normal file
60
src/app/example/dashboard/overview/@bar_stats/error.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { BarGraphSkeleton } from '@/features/overview/components/bar-graph-skeleton';
|
||||
|
||||
export default function Loading() {
|
||||
return <BarGraphSkeleton />;
|
||||
}
|
||||
8
src/app/example/dashboard/overview/@bar_stats/page.tsx
Normal file
8
src/app/example/dashboard/overview/@bar_stats/page.tsx
Normal 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 />;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export default function Default() {
|
||||
return null;
|
||||
}
|
||||
14
src/app/example/dashboard/overview/@pie_stats/error.tsx
Normal file
14
src/app/example/dashboard/overview/@pie_stats/error.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PieGraphSkeleton } from '@/features/overview/components/pie-graph-skeleton';
|
||||
|
||||
export default function Loading() {
|
||||
return <PieGraphSkeleton />;
|
||||
}
|
||||
7
src/app/example/dashboard/overview/@pie_stats/page.tsx
Normal file
7
src/app/example/dashboard/overview/@pie_stats/page.tsx
Normal 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 />;
|
||||
}
|
||||
3
src/app/example/dashboard/overview/@sales/default.tsx
Normal file
3
src/app/example/dashboard/overview/@sales/default.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function Default() {
|
||||
return null;
|
||||
}
|
||||
14
src/app/example/dashboard/overview/@sales/error.tsx
Normal file
14
src/app/example/dashboard/overview/@sales/error.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
6
src/app/example/dashboard/overview/@sales/loading.tsx
Normal file
6
src/app/example/dashboard/overview/@sales/loading.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { RecentSalesSkeleton } from '@/features/overview/components/recent-sales-skeleton';
|
||||
import React from 'react';
|
||||
|
||||
export default function Loading() {
|
||||
return <RecentSalesSkeleton />;
|
||||
}
|
||||
7
src/app/example/dashboard/overview/@sales/page.tsx
Normal file
7
src/app/example/dashboard/overview/@sales/page.tsx
Normal 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 />;
|
||||
}
|
||||
14
src/app/example/dashboard/overview/error.tsx
Normal file
14
src/app/example/dashboard/overview/error.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
126
src/app/example/dashboard/overview/layout.tsx
Normal file
126
src/app/example/dashboard/overview/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
9
src/app/example/dashboard/overview/page.tsx
Normal file
9
src/app/example/dashboard/overview/page.tsx
Normal 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 />;
|
||||
}
|
||||
5
src/app/example/dashboard/page.tsx
Normal file
5
src/app/example/dashboard/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function ExampleDashboardIndexPage() {
|
||||
redirect('/example/dashboard/overview');
|
||||
}
|
||||
30
src/app/example/dashboard/product/[productId]/page.tsx
Normal file
30
src/app/example/dashboard/product/[productId]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
47
src/app/example/dashboard/product/page.tsx
Normal file
47
src/app/example/dashboard/product/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
29
src/app/example/dashboard/react-query/page.tsx
Normal file
29
src/app/example/dashboard/react-query/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
47
src/app/example/dashboard/users/page.tsx
Normal file
47
src/app/example/dashboard/users/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
194
src/app/example/dashboard/workspaces/page.tsx
Normal file
194
src/app/example/dashboard/workspaces/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
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
23
src/app/global-error.tsx
Normal 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
Reference in New Issue
Block a user