task-b complate

This commit is contained in:
phaichayon
2026-06-11 16:55:45 +07:00
parent 7a390cf0df
commit 67545d9295
135 changed files with 3295 additions and 6385 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,31 +0,0 @@
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 });
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,60 @@
import { NextRequest, NextResponse } from 'next/server';
import { listMasterOptions } from '@/features/foundation/master-options/service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
export async function GET(request: NextRequest) {
try {
const { organization } = await requireOrganizationAccess({
permission: PERMISSIONS.organizationManage
});
const { searchParams } = request.nextUrl;
const page = Number(searchParams.get('page') ?? 1);
const limit = Number(searchParams.get('limit') ?? 10);
const search = searchParams.get('search') ?? undefined;
const category = searchParams.get('category') ?? undefined;
const sort = searchParams.get('sort') ?? undefined;
const result = await listMasterOptions({
organizationId: organization.id,
page,
limit,
search,
category,
sort
});
const offset = (page - 1) * limit;
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'Master options loaded successfully',
total_items: result.totalItems,
offset,
limit,
items: result.items.map((item) => ({
id: item.id,
organization_id: item.organizationId,
category: item.category,
code: item.code,
label: item.label,
value: item.value,
parent_id: item.parentId,
parent_label: item.parentLabel,
sort_order: item.sortOrder,
is_active: item.isActive,
deleted_at: item.deletedAt,
updated_at: item.updatedAt
}))
});
} 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 load master options' }, { status: 500 });
}
}

View File

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

View File

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

View File

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

View File

@@ -1,21 +1,37 @@
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import { auth } from '@/auth';
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';
import MasterOptionsListingPage from '@/features/foundation/master-options/components/master-options-listing';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { searchParamsCache } from '@/lib/searchparams';
import type { SearchParams } from 'nuqs/server';
export default function MasterOptionsRoute() {
const queryClient = getQueryClient();
void queryClient.prefetchQuery(crmReferenceQueryOptions());
type PageProps = {
searchParams: Promise<SearchParams>;
};
export default async function MasterOptionsRoute(props: PageProps) {
const searchParams = await props.searchParams;
const session = await auth();
const canManageOptions =
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.organizationManage)));
searchParamsCache.parse(searchParams);
return (
<PageContainer
pageTitle='Master Options'
pageDescription='Mock UI สำหรับ status options, product types, payment term, currency และ tax rate'
pageDescription='Organization-scoped CRM option registry for statuses, product types, payment terms, currency, and branch abstractions.'
access={canManageOptions}
accessFallback={
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
You do not have access to master option management.
</div>
}
>
<HydrationBoundary state={dehydrate(queryClient)}>
<MasterOptionsPage />
</HydrationBoundary>
<MasterOptionsListingPage />
</PageContainer>
);
}

View File

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

View File

@@ -19,8 +19,8 @@ export default async function ExclusivePage() {
<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.
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'>

View File

@@ -1,26 +1,37 @@
import type { ReactNode } from 'react';
import KBar from '@/components/kbar';
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';
import type { Metadata } from 'next';
import { cookies } from 'next/headers';
export default function DashboardLayout({ children }: { children: ReactNode }) {
export const metadata: Metadata = {
title: 'Next Shadcn Dashboard Starter',
description: 'Basic dashboard with Next.js and Shadcn',
robots: {
index: false,
follow: false
}
};
export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
// Persisting the sidebar state in the cookie.
const cookieStore = await cookies();
const defaultOpen = cookieStore.get('sidebar_state')?.value === 'true';
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>
<SidebarProvider defaultOpen={defaultOpen}>
<AppSidebar />
<SidebarInset>
<Header />
<InfobarProvider defaultOpen={false}>
{children}
<InfoSidebar side='right' />
</InfobarProvider>
</SidebarInset>
</SidebarProvider>
</KBar>
);
}

View File

@@ -1,5 +1,5 @@
import { redirect } from 'next/navigation';
import OverViewPage from '@/features/overview/components/overview';
export default function DashboardIndexPage() {
redirect('/dashboard/crm');
export default function DashboardPage() {
return <OverViewPage />;
}

View File

@@ -0,0 +1,51 @@
import { auth } from '@/auth';
import PageContainer from '@/components/layout/page-container';
import { buttonVariants } from '@/components/ui/button';
import ProductListingPage from '@/features/products/components/product-listing';
import { searchParamsCache } from '@/lib/searchparams';
import { cn } from '@/lib/utils';
import { Icons } from '@/components/icons';
import Link from 'next/link';
import { SearchParams } from 'nuqs/server';
import { productInfoContent } from '@/config/infoconfig';
export const metadata = {
title: 'Dashboard: Products'
};
type pageProps = {
searchParams: Promise<SearchParams>;
};
export default async function Page(props: pageProps) {
const searchParams = await props.searchParams;
const session = await auth();
const hasActiveOrganization = !!session?.user?.activeOrganizationId;
searchParamsCache.parse(searchParams);
return (
<PageContainer
pageTitle='Products'
pageDescription='Manage products (React Query + nuqs table pattern.)'
infoContent={productInfoContent}
access={hasActiveOrganization}
accessFallback={
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
Select an active workspace before managing products.
</div>
}
pageHeaderAction={
hasActiveOrganization ? (
<Link
href='/dashboard/product/new'
className={cn(buttonVariants(), 'text-xs md:text-sm')}
>
<Icons.add className='mr-2 h-4 w-4' /> Add New
</Link>
) : null
}
>
<ProductListingPage canPrefetch={hasActiveOrganization} />
</PageContainer>
);
}

View File

@@ -1,23 +1,27 @@
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 PageContainer from '@/components/layout/page-container';
import { Suspense } from 'react';
import { PokemonSkeleton } from '@/features/react-query-demo/components/pokemon-skeleton';
import { reactQueryInfoContent } from '@/features/react-query-demo/info-content';
export const metadata = {
title: 'Example Dashboard: React Query'
title: 'Dashboard: React Query'
};
export default function ExampleReactQueryPage() {
export default function ReactQueryPage() {
const queryClient = getQueryClient();
// Prefetch on the server — data is ready before client JS loads
void queryClient.prefetchQuery(pokemonOptions(25));
return (
<PageContainer
pageTitle='React Query'
pageDescription='Server prefetch + hydration + suspense query pattern in a public example route.'
pageDescription='Server prefetch + client hydration + suspense query pattern.'
infoContent={reactQueryInfoContent}
>
<HydrationBoundary state={dehydrate(queryClient)}>
<Suspense fallback={<PokemonSkeleton />}>

View File

@@ -1,47 +1,44 @@
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import { auth } from '@/auth';
import PageContainer from '@/components/layout/page-container';
import { getQueryClient } from '@/lib/query-client';
import UserListingPage from '@/features/users/components/user-listing';
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';
import { usersInfoContent } from '@/features/users/info-content';
import { UserFormSheetTrigger } from '@/features/users/components/user-form-sheet';
export const metadata = {
title: 'Dashboard: Users'
};
type PageProps = {
searchParams: Promise<SearchParams>;
};
export const metadata = {
title: 'Example Dashboard: Users'
};
export default async function ExampleUsersPage(props: PageProps) {
export default async function UsersPage(props: PageProps) {
const searchParams = await props.searchParams;
const session = await auth();
const canManageUsers =
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes('users:manage')));
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.'
pageDescription='Manage users (React Query + nuqs table pattern.)'
infoContent={usersInfoContent}
access={canManageUsers}
accessFallback={
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
You do not have access to user management.
</div>
}
pageHeaderAction={canManageUsers ? <UserFormSheetTrigger /> : null}
>
<HydrationBoundary state={dehydrate(queryClient)}>
<ExampleUserTable />
</HydrationBoundary>
<UserListingPage />
</PageContainer>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,14 +6,13 @@ import ThemeProvider from '@/components/themes/theme-provider';
import { cn } from '@/lib/utils';
import type { Metadata, Viewport } from 'next';
import { cookies } from 'next/headers';
import Script from 'next/script';
import NextTopLoader from 'nextjs-toploader';
import { NuqsAdapter } from 'nuqs/adapters/next/app';
import '../styles/globals.css';
const META_THEME_COLORS = {
light: '#ffffff',
dark: '#09090b'
light: '#f9fbfa',
dark: '#001e2b'
};
export const metadata: Metadata = {
@@ -34,16 +33,18 @@ export default async function RootLayout({ children }: { children: React.ReactNo
return (
<html lang='en' suppressHydrationWarning data-theme={themeToApply}>
<head>
<Script id='theme-color-init' strategy='beforeInteractive'>
{`
try {
// Keep the browser chrome color in sync before hydration.
if (localStorage.theme === 'dark' || ((!('theme' in localStorage) || localStorage.theme === 'system') && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.querySelector('meta[name="theme-color"]')?.setAttribute('content', '${META_THEME_COLORS.dark}')
}
} catch (_) {}
`}
</Script>
<script
dangerouslySetInnerHTML={{
__html: `
try {
// Set meta theme color
if (localStorage.theme === 'dark' || ((!('theme' in localStorage) || localStorage.theme === 'system') && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.querySelector('meta[name="theme-color"]')?.setAttribute('content', '${META_THEME_COLORS.dark}')
}
} catch (_) {}
`
}}
/>
</head>
<body
className={cn(

View File

@@ -1,5 +1,12 @@
import { redirect } from "next/navigation";
import { auth } from '@/auth';
import { redirect } from 'next/navigation';
export default function Page() {
redirect("/dashboard");
export default async function Page() {
const session = await auth();
if (!session?.user?.id) {
return redirect('/auth/sign-in');
} else {
redirect('/dashboard');
}
}

View File

@@ -1,9 +0,0 @@
import { SetupWizard } from '@/features/setup/components/setup-wizard';
export const metadata = {
title: 'Setup Wizard'
};
export default function SetupPage() {
return <SetupWizard />;
}

View File

@@ -83,49 +83,38 @@ export default function AppSidebar() {
<SidebarMenu>
{group.items.map((item) => {
const Icon = item.icon ? Icons[item.icon] : Icons.logo;
const itemKey = item.url || item.title;
const childItems = item.items?.filter((subItem) => Boolean(subItem.url)) ?? [];
if (childItems.length > 0) {
return (
<Collapsible
key={itemKey}
asChild
defaultOpen={item.isActive}
className='group/collapsible'
>
<SidebarMenuItem>
<CollapsibleTrigger asChild>
<SidebarMenuButton tooltip={item.title} isActive={pathname === item.url}>
{item.icon && <Icon />}
<span>{item.title}</span>
<Icons.chevronRight className='ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90' />
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
{childItems.map((subItem) => (
<SidebarMenuSubItem key={subItem.url || subItem.title}>
<SidebarMenuSubButton asChild isActive={pathname === subItem.url}>
<Link href={subItem.url}>
<span>{subItem.title}</span>
</Link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</CollapsibleContent>
</SidebarMenuItem>
</Collapsible>
);
}
if (!item.url) {
return null;
}
return (
<SidebarMenuItem key={itemKey}>
return item?.items && item?.items?.length > 0 ? (
<Collapsible
key={item.title}
asChild
defaultOpen={item.isActive}
className='group/collapsible'
>
<SidebarMenuItem>
<CollapsibleTrigger asChild>
<SidebarMenuButton tooltip={item.title} isActive={pathname === item.url}>
{item.icon && <Icon />}
<span>{item.title}</span>
<Icons.chevronRight className='ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90' />
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
{item.items?.map((subItem) => (
<SidebarMenuSubItem key={subItem.title}>
<SidebarMenuSubButton asChild isActive={pathname === subItem.url}>
<Link href={subItem.url}>
<span>{subItem.title}</span>
</Link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</CollapsibleContent>
</SidebarMenuItem>
</Collapsible>
) : (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton
asChild
tooltip={item.title}

View File

@@ -2,9 +2,13 @@
* Default theme that loads when no user preference is set
* Change this value to set a different default theme
*/
export const DEFAULT_THEME = 'vercel';
export const DEFAULT_THEME = 'mongodb';
export const THEMES = [
{
name: 'MongoDB',
value: 'mongodb'
},
{
name: 'Claude',
value: 'claude'

View File

@@ -1,4 +1,4 @@
import { NavGroup } from "@/types";
import { NavGroup } from '@/types';
/**
* Navigation configuration with RBAC support
@@ -35,118 +35,157 @@ import { NavGroup } from "@/types";
*/
export const navGroups: NavGroup[] = [
{
label: "Overview",
label: 'Overview',
items: [
{
title: "Dashboard",
url: "/dashboard",
icon: "dashboard",
title: 'Dashboard',
url: '/dashboard',
icon: 'dashboard',
isActive: false,
shortcut: ["d", "d"],
items: [],
shortcut: ['d', 'd'],
items: []
},
{
title: "Workspaces",
url: "/dashboard/workspaces",
icon: "workspace",
title: 'Workspaces',
url: '/dashboard/workspaces',
icon: 'workspace',
isActive: false,
items: [],
access: { systemRole: "super_admin" },
access: { systemRole: 'super_admin' }
},
{
title: "Teams",
url: "/dashboard/workspaces/team",
icon: "teams",
title: 'Teams',
url: '/dashboard/workspaces/team',
icon: 'teams',
isActive: false,
items: [],
access: { requireOrg: true, role: "admin" },
access: { requireOrg: true, role: 'admin' }
},
{
title: "Users",
url: "/dashboard/users",
icon: "teams",
shortcut: ["u", "u"],
title: 'Users',
url: '/dashboard/users',
icon: 'teams',
shortcut: ['u', 'u'],
isActive: false,
items: [],
access: { requireOrg: true, permission: "users:manage" },
access: { requireOrg: true, permission: 'users:manage' }
},
{
title: "Kanban",
url: "/dashboard/kanban",
icon: "kanban",
shortcut: ["k", "k"],
title: 'Kanban',
url: '/dashboard/kanban',
icon: 'kanban',
shortcut: ['k', 'k'],
isActive: false,
items: [],
},
],
},
{
label: "CRM",
items: [
{
title: "CRM Dashboard",
url: "/dashboard/crm",
icon: "dashboard",
isActive: false,
items: [],
access: { requireOrg: true },
},
{
title: "Enquiries",
url: "/dashboard/crm/enquiries",
icon: "forms",
isActive: false,
items: [],
access: { requireOrg: true },
},
{
title: "Customers",
url: "/dashboard/crm/customers",
icon: "teams",
isActive: false,
items: [],
access: { requireOrg: true },
},
{
title: "Quotations",
url: "/dashboard/crm/quotations",
icon: "fileTypePdf",
isActive: false,
items: [],
access: { requireOrg: true },
},
{
title: "Approvals",
url: "/dashboard/crm/approvals",
icon: "checks",
isActive: false,
items: [],
access: { requireOrg: true },
},
{
title: "CRM Settings",
url: "#",
icon: "settings",
isActive: true,
access: { requireOrg: true },
items: [
{
title: "Master Options",
url: "/dashboard/crm/settings/master-options",
icon: "settings",
},
{
title: "Document Sequences",
url: "/dashboard/crm/settings/document-sequences",
icon: "post",
},
{
title: "Templates",
url: "/dashboard/crm/settings/templates",
icon: "page",
},
],
},
],
},
items: []
}
// {
// title: "Chat",
// url: "/dashboard/chat",
// icon: "chat",
// shortcut: ["c", "c"],
// isActive: false,
// items: [],
// },
]
}
// {
// label: "Elements",
// items: [
// {
// title: "Forms",
// url: "#",
// icon: "forms",
// isActive: true,
// items: [
// {
// title: "Basic Form",
// url: "/dashboard/forms/basic",
// icon: "forms",
// shortcut: ["f", "f"],
// },
// {
// title: "Multi-Step Form",
// url: "/dashboard/forms/multi-step",
// icon: "forms",
// },
// {
// title: "Sheet & Dialog",
// url: "/dashboard/forms/sheet-form",
// icon: "forms",
// },
// {
// title: "Advanced Patterns",
// url: "/dashboard/forms/advanced",
// icon: "forms",
// },
// ],
// },
// {
// title: "React Query",
// url: "/dashboard/react-query",
// icon: "code",
// isActive: false,
// items: [],
// },
// {
// title: "Icons",
// url: "/dashboard/elements/icons",
// icon: "palette",
// isActive: false,
// items: [],
// },
// ],
// },
// {
// label: "",
// items: [
// {
// title: "Pro",
// url: "#",
// icon: "pro",
// isActive: true,
// items: [
// {
// title: "Exclusive",
// url: "/dashboard/exclusive",
// icon: "exclusive",
// shortcut: ["e", "e"],
// },
// ],
// },
// {
// title: "Account",
// url: "#",
// icon: "account",
// isActive: true,
// items: [
// {
// title: "Profile",
// url: "/dashboard/profile",
// icon: "profile",
// shortcut: ["m", "m"],
// },
// {
// title: "Notifications",
// url: "/dashboard/notifications",
// icon: "notification",
// shortcut: ["n", "n"],
// },
// {
// title: "Billing",
// url: "/dashboard/billing",
// icon: "billing",
// shortcut: ["b", "b"],
// access: { requireOrg: true, role: "admin" },
// },
// {
// title: "Login",
// shortcut: ["l", "l"],
// url: "/",
// icon: "login",
// },
// ],
// },
// ],
// },
];

View File

@@ -1,84 +1,130 @@
import {
boolean,
doublePrecision,
integer,
jsonb,
numeric,
pgTable,
text,
timestamp,
uniqueIndex,
} from "drizzle-orm/pg-core";
uniqueIndex
} from 'drizzle-orm/pg-core';
export const users = pgTable(
"users",
'users',
{
id: text("id").primaryKey(),
name: text("name").notNull(),
email: text("email").notNull(),
passwordHash: text("password_hash").notNull(),
systemRole: text("system_role").default("user").notNull(),
image: text("image"),
activeOrganizationId: text("active_organization_id"),
createdAt: timestamp("created_at", { withTimezone: true })
.defaultNow()
.notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.defaultNow()
.notNull(),
id: text('id').primaryKey(),
name: text('name').notNull(),
email: text('email').notNull(),
passwordHash: text('password_hash').notNull(),
systemRole: text('system_role').default('user').notNull(),
image: text('image'),
activeOrganizationId: text('active_organization_id'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull()
},
(table) => ({
emailIdx: uniqueIndex("users_email_idx").on(table.email),
}),
emailIdx: uniqueIndex('users_email_idx').on(table.email)
})
);
export const organizations = pgTable(
"organizations",
'organizations',
{
id: text("id").primaryKey(),
name: text("name").notNull(),
slug: text("slug").notNull(),
imageUrl: text("image_url"),
plan: text("plan").default("free").notNull(),
createdBy: text("created_by").notNull(),
createdAt: timestamp("created_at", { withTimezone: true })
.defaultNow()
.notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.defaultNow()
.notNull(),
id: text('id').primaryKey(),
name: text('name').notNull(),
slug: text('slug').notNull(),
imageUrl: text('image_url'),
plan: text('plan').default('free').notNull(),
createdBy: text('created_by').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull()
},
(table) => ({
slugIdx: uniqueIndex("organizations_slug_idx").on(table.slug),
}),
slugIdx: uniqueIndex('organizations_slug_idx').on(table.slug)
})
);
export const memberships = pgTable("memberships", {
id: text("id").primaryKey(),
userId: text("user_id").notNull(),
organizationId: text("organization_id").notNull(),
role: text("role").default("user").notNull(),
businessRole: text("business_role").default("viewer").notNull(),
permissions: text("permissions").array().default([]).notNull(),
createdAt: timestamp("created_at", { withTimezone: true })
.defaultNow()
.notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.defaultNow()
.notNull(),
export const memberships = pgTable('memberships', {
id: text('id').primaryKey(),
userId: text('user_id').notNull(),
organizationId: text('organization_id').notNull(),
role: text('role').default('user').notNull(),
businessRole: text('business_role').default('viewer').notNull(),
permissions: text('permissions').array().default([]).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull()
});
export const products = pgTable("products", {
id: integer("id").primaryKey().generatedAlwaysAsIdentity(),
organizationId: text("organization_id").notNull(),
name: text("name").notNull(),
category: text("category").notNull(),
description: text("description").notNull(),
photoUrl: text("photo_url").notNull(),
price: doublePrecision("price").notNull(),
createdAt: timestamp("created_at", { withTimezone: true })
.defaultNow()
.notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.defaultNow()
.notNull(),
export const products = pgTable('products', {
id: integer('id').primaryKey().generatedAlwaysAsIdentity(),
organizationId: text('organization_id').notNull(),
name: text('name').notNull(),
category: text('category').notNull(),
description: text('description').notNull(),
photoUrl: text('photo_url').notNull(),
price: doublePrecision('price').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull()
});
export const msOptions = pgTable(
'ms_options',
{
id: text('id').primaryKey(),
organizationId: text('organization_id').notNull(),
category: text('category').notNull(),
code: text('code').notNull(),
label: text('label').notNull(),
value: text('value'),
parentId: text('parent_id'),
sortOrder: integer('sort_order').default(0).notNull(),
isActive: boolean('is_active').default(true).notNull(),
metadata: jsonb('metadata'),
deletedAt: timestamp('deleted_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull()
},
(table) => ({
organizationCategoryCodeIdx: uniqueIndex('ms_options_org_category_code_idx').on(
table.organizationId,
table.category,
table.code
)
})
);
export const documentSequences = pgTable(
'document_sequences',
{
id: text('id').primaryKey(),
organizationId: text('organization_id').notNull(),
branchId: text('branch_id').default('').notNull(),
documentType: text('document_type').notNull(),
prefix: text('prefix').notNull(),
period: text('period').notNull(),
currentNumber: integer('current_number').default(0).notNull(),
paddingLength: integer('padding_length').default(3).notNull(),
isActive: boolean('is_active').default(true).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull()
},
(table) => ({
organizationDocumentPeriodBranchIdx: uniqueIndex(
'document_sequences_org_doc_period_branch_idx'
).on(table.organizationId, table.documentType, table.period, table.branchId)
})
);
export const trAuditLogs = pgTable('tr_audit_logs', {
id: text('id').primaryKey(),
organizationId: text('organization_id').notNull(),
branchId: text('branch_id'),
userId: text('user_id').notNull(),
entityType: text('entity_type').notNull(),
entityId: text('entity_id').notNull(),
action: text('action').notNull(),
beforeData: jsonb('before_data'),
afterData: jsonb('after_data'),
requestId: text('request_id'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull()
});

View File

@@ -1,99 +0,0 @@
import { mutationOptions } from '@tanstack/react-query';
import { getQueryClient } from '@/lib/query-client';
import {
assignAsset,
createAsset,
createMasterData,
deleteAsset,
deleteMasterData,
repairAsset,
returnAsset,
transferAsset,
updateAsset,
updateMasterData
} from './service';
import { assetKeys } from './queries';
import type {
AssetAssignmentPayload,
AssetMutationPayload,
AssetRepairPayload,
AssetReturnPayload,
AssetTransferPayload,
MasterDataEntity,
MasterDataMutationPayload
} from './types';
function invalidateAssets() {
const queryClient = getQueryClient();
queryClient.invalidateQueries({ queryKey: assetKeys.all });
}
export const createAssetMutation = mutationOptions({
mutationFn: (data: AssetMutationPayload) => createAsset(data),
onSuccess: invalidateAssets
});
export const updateAssetMutation = mutationOptions({
mutationFn: ({ id, values }: { id: string; values: AssetMutationPayload }) =>
updateAsset(id, values),
onSuccess: invalidateAssets
});
export const deleteAssetMutation = mutationOptions({
mutationFn: (id: string) => deleteAsset(id),
onSuccess: invalidateAssets
});
export const assignAssetMutation = mutationOptions({
mutationFn: ({ id, values }: { id: string; values: AssetAssignmentPayload }) =>
assignAsset(id, values),
onSuccess: invalidateAssets
});
export const transferAssetMutation = mutationOptions({
mutationFn: ({ id, values }: { id: string; values: AssetTransferPayload }) =>
transferAsset(id, values),
onSuccess: invalidateAssets
});
export const returnAssetMutation = mutationOptions({
mutationFn: ({ id, values }: { id: string; values: AssetReturnPayload }) =>
returnAsset(id, values),
onSuccess: invalidateAssets
});
export const repairAssetMutation = mutationOptions({
mutationFn: ({ id, values }: { id: string; values: AssetRepairPayload }) =>
repairAsset(id, values),
onSuccess: invalidateAssets
});
export const createMasterDataMutation = mutationOptions({
mutationFn: ({
entity,
values
}: {
entity: MasterDataEntity;
values: MasterDataMutationPayload;
}) => createMasterData(entity, values),
onSuccess: invalidateAssets
});
export const updateMasterDataMutation = mutationOptions({
mutationFn: ({
entity,
id,
values
}: {
entity: MasterDataEntity;
id: string;
values: MasterDataMutationPayload;
}) => updateMasterData(entity, id, values),
onSuccess: invalidateAssets
});
export const deleteMasterDataMutation = mutationOptions({
mutationFn: ({ entity, id }: { entity: MasterDataEntity; id: string }) =>
deleteMasterData(entity, id),
onSuccess: invalidateAssets
});

View File

@@ -1,84 +0,0 @@
import { queryOptions } from '@tanstack/react-query';
import {
getAssetById,
getAssetHistory,
getAssetOptions,
getAssets,
getAssetSummary,
getMasterData
} from './service';
import type { AssetFilters, Department, Employee, Location, MasterDataEntity, Site } from './types';
export const assetKeys = {
all: ['assets'] as const,
lists: () => [...assetKeys.all, 'list'] as const,
list: (filters: AssetFilters) => [...assetKeys.lists(), filters] as const,
detail: (id: string) => [...assetKeys.all, 'detail', id] as const,
summary: () => [...assetKeys.all, 'summary'] as const,
options: () => [...assetKeys.all, 'options'] as const,
history: () => [...assetKeys.all, 'history'] as const,
masterData: (entity: MasterDataEntity) => [...assetKeys.all, 'master-data', entity] as const
};
export function assetsQueryOptions(filters: AssetFilters) {
return queryOptions({
queryKey: assetKeys.list(filters),
queryFn: () => getAssets(filters)
});
}
export function assetByIdQueryOptions(id: string) {
return queryOptions({
queryKey: assetKeys.detail(id),
queryFn: () => getAssetById(id)
});
}
export function assetSummaryQueryOptions() {
return queryOptions({
queryKey: assetKeys.summary(),
queryFn: getAssetSummary
});
}
export function assetOptionsQueryOptions() {
return queryOptions({
queryKey: assetKeys.options(),
queryFn: getAssetOptions
});
}
export function assetHistoryQueryOptions() {
return queryOptions({
queryKey: assetKeys.history(),
queryFn: getAssetHistory
});
}
export function masterDataQueryOptions(entity: 'sites') {
return queryOptions({
queryKey: assetKeys.masterData(entity),
queryFn: () => getMasterData<Site>(entity)
});
}
export function departmentQueryOptions(entity: 'departments') {
return queryOptions({
queryKey: assetKeys.masterData(entity),
queryFn: () => getMasterData<Department>(entity)
});
}
export function locationQueryOptions(entity: 'locations') {
return queryOptions({
queryKey: assetKeys.masterData(entity),
queryFn: () => getMasterData<Location>(entity)
});
}
export function employeeQueryOptions(entity: 'employees') {
return queryOptions({
queryKey: assetKeys.masterData(entity),
queryFn: () => getMasterData<Employee>(entity)
});
}

View File

@@ -1,125 +0,0 @@
import { apiClient } from '@/lib/api-client';
import type {
AssetAssignmentPayload,
AssetByIdResponse,
AssetDashboardSummary,
AssetFilters,
AssetMutationPayload,
AssetOptionsResponse,
AssetRepairPayload,
AssetReturnPayload,
AssetsResponse,
AssetTransferPayload,
MasterDataEntity,
MasterDataListResponse,
MasterDataMutationPayload
} from './types';
export async function getAssets(filters: AssetFilters): Promise<AssetsResponse> {
const searchParams = new URLSearchParams();
if (filters.page) searchParams.set('page', String(filters.page));
if (filters.limit) searchParams.set('limit', String(filters.limit));
if (filters.search) searchParams.set('search', filters.search);
if (filters.status) searchParams.set('status', filters.status);
if (filters.assetCondition) searchParams.set('assetCondition', filters.assetCondition);
if (filters.dispositionStatus) searchParams.set('dispositionStatus', filters.dispositionStatus);
if (filters.assetType) searchParams.set('assetType', filters.assetType);
if (filters.sort) searchParams.set('sort', filters.sort);
return apiClient<AssetsResponse>(`/assets?${searchParams.toString()}`);
}
export async function getAssetById(id: string): Promise<AssetByIdResponse> {
return apiClient<AssetByIdResponse>(`/assets/${id}`);
}
export async function createAsset(data: AssetMutationPayload) {
return apiClient<{ success: boolean; message: string; id: string }>('/assets', {
method: 'POST',
body: JSON.stringify(data)
});
}
export async function updateAsset(id: string, data: AssetMutationPayload) {
return apiClient<{ success: boolean; message: string }>(`/assets/${id}`, {
method: 'PUT',
body: JSON.stringify(data)
});
}
export async function deleteAsset(id: string) {
return apiClient<{ success: boolean; message: string }>(`/assets/${id}`, {
method: 'DELETE'
});
}
export async function assignAsset(id: string, data: AssetAssignmentPayload) {
return apiClient<{ success: boolean; message: string }>(`/assets/${id}/assign`, {
method: 'POST',
body: JSON.stringify(data)
});
}
export async function transferAsset(id: string, data: AssetTransferPayload) {
return apiClient<{ success: boolean; message: string }>(`/assets/${id}/transfer`, {
method: 'POST',
body: JSON.stringify(data)
});
}
export async function returnAsset(id: string, data: AssetReturnPayload) {
return apiClient<{ success: boolean; message: string }>(`/assets/${id}/return`, {
method: 'POST',
body: JSON.stringify(data)
});
}
export async function repairAsset(id: string, data: AssetRepairPayload) {
return apiClient<{ success: boolean; message: string }>(`/assets/${id}/repair`, {
method: 'POST',
body: JSON.stringify(data)
});
}
export async function getAssetSummary() {
return apiClient<AssetDashboardSummary>('/assets/dashboard/summary');
}
export async function getAssetOptions() {
return apiClient<AssetOptionsResponse>('/assets/options');
}
export async function getAssetHistory() {
return apiClient<{ success: boolean; time: string; message: string; movements: AssetByIdResponse['movements'] }>(
'/assets/history'
);
}
export async function getMasterData<TItem>(entity: MasterDataEntity) {
return apiClient<MasterDataListResponse<TItem>>(`/master-data/${entity}`);
}
export async function createMasterData(entity: MasterDataEntity, data: MasterDataMutationPayload) {
return apiClient<{ success: boolean; message: string; id: string }>(`/master-data/${entity}`, {
method: 'POST',
body: JSON.stringify(data)
});
}
export async function updateMasterData(
entity: MasterDataEntity,
id: string,
data: MasterDataMutationPayload
) {
return apiClient<{ success: boolean; message: string }>(`/master-data/${entity}/${id}`, {
method: 'PUT',
body: JSON.stringify(data)
});
}
export async function deleteMasterData(entity: MasterDataEntity, id: string) {
return apiClient<{ success: boolean; message: string }>(`/master-data/${entity}/${id}`, {
method: 'DELETE'
});
}

View File

@@ -1,254 +0,0 @@
export type BusinessRole =
| 'it_admin'
| 'helpdesk'
| 'infrastructure'
| 'application'
| 'auditor'
| 'viewer';
export type AssetStatus = 'AVAILABLE' | 'ASSIGNED' | 'IN_REPAIR' | 'LOST' | 'RETIRED';
export type AssetCondition = 'NORMAL' | 'DAMAGED';
export type DispositionStatus =
| 'NONE'
| 'WAITING_REPAIR'
| 'WAITING_WRITE_OFF'
| 'WRITE_OFF_COMPLETED';
export type MovementType =
| 'CREATE'
| 'ASSIGN'
| 'TRANSFER'
| 'CHANGE_USER'
| 'CHANGE_LOCATION'
| 'CHANGE_DEPARTMENT'
| 'CHANGE_CODE'
| 'RETURN'
| 'REPAIR';
export type MasterDataEntity = 'sites' | 'departments' | 'locations' | 'employees';
export interface Site {
id: string;
organizationId: string;
code: string;
name: string;
}
export interface Department {
id: string;
organizationId: string;
code: string;
name: string;
}
export interface Location {
id: string;
organizationId: string;
siteId: string | null;
siteName: string | null;
building: string | null;
floor: string | null;
area: string | null;
name: string;
}
export interface Employee {
id: string;
organizationId: string;
employeeNo: string;
name: string;
departmentId: string | null;
departmentName: string | null;
siteId: string | null;
siteName: string | null;
status: string;
}
export interface Asset {
id: string;
organizationId: string;
assetUid: string;
assetCode: string;
assetName: string;
companyName: string | null;
assetType: 'hardware' | 'software';
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: string | null;
warrantyExpiryDate: string | null;
eosDate: string | null;
eolDate: string | null;
eopDate: string | null;
status: AssetStatus;
assetCondition: AssetCondition;
dispositionStatus: DispositionStatus;
notes: string | null;
createdAt: string;
updatedAt: string;
}
export interface AssetMovement {
id: string;
assetId: string;
assetUid: string;
assetCode: string;
assetName: string;
eventType: MovementType;
eventDate: string;
reason: string | null;
referenceDocument: string | null;
performedByName: string | null;
metadata: Record<string, unknown> | null;
}
export interface AssetRepair {
id: string;
assetId: string;
repairDate: string;
vendor: string | null;
problem: string;
resolution: string | null;
cost: number | null;
}
export interface AssetDashboardSummary {
cards: {
totalAssets: number;
assignedAssets: number;
inStockAssets: number;
transferThisMonth: number;
withoutUser: number;
withoutLocation: number;
};
}
export interface AssetFilters {
page?: number;
limit?: number;
search?: string;
status?: string;
assetCondition?: string;
dispositionStatus?: string;
assetType?: string;
sort?: string;
}
export interface AssetsResponse {
success: boolean;
time: string;
message: string;
total_assets: number;
offset: number;
limit: number;
assets: Asset[];
}
export interface AssetByIdResponse {
success: boolean;
time: string;
message: string;
asset: Asset;
movements: AssetMovement[];
repairs: AssetRepair[];
}
export interface AssetMutationPayload {
assetCode: string;
assetName: string;
assetType: 'hardware' | 'software';
assetCategory: string;
brand?: string;
model?: string;
specification?: string;
serialNumber?: string;
siteId?: string | null;
departmentId?: string | null;
locationId?: string | null;
currentEmployeeId?: string | null;
custodianTeam?: string;
purchaseDate?: string | null;
warrantyExpiryDate?: string | null;
eosDate?: string | null;
eolDate?: string | null;
eopDate?: string | null;
status: AssetStatus;
assetCondition: AssetCondition;
dispositionStatus: DispositionStatus;
notes?: string;
}
export interface AssetAssignmentPayload {
employeeId: string;
departmentId?: string | null;
locationId?: string | null;
siteId?: string | null;
assignDate: string;
documentAttachment: string;
reason?: string;
}
export interface AssetTransferPayload {
newEmployeeId?: string | null;
newDepartmentId?: string | null;
newLocationId?: string | null;
newSiteId?: string | null;
newAssetCode?: string;
transferDate: string;
referenceDocument: string;
reason?: string;
}
export interface AssetReturnPayload {
returnDate: string;
assetCondition: AssetCondition;
remark: string;
}
export interface AssetRepairPayload {
repairDate?: string | null;
vendor?: string;
problem: string;
resolution?: string;
cost?: number | null;
markAsRepair?: boolean;
}
export interface MasterDataMutationPayload {
code?: string;
name: string;
siteId?: string | null;
building?: string;
floor?: string;
area?: string;
employeeNo?: string;
departmentId?: string | null;
status?: string;
}
export interface MasterDataListResponse<TItem> {
success: boolean;
time: string;
message: string;
items: TItem[];
}
export interface AssetOptionsResponse {
success: boolean;
time: string;
message: string;
sites: Site[];
departments: Department[];
locations: Location[];
employees: Employee[];
}

View File

@@ -1,327 +0,0 @@
'use client';
import { useMutation, useSuspenseQuery } from '@tanstack/react-query';
import { useRouter } from 'next/navigation';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { useAppForm, useFormFields } from '@/components/ui/tanstack-form';
import { assetConditionOptions } from '../constants';
import { assetByIdQueryOptions, assetOptionsQueryOptions } from '../api/queries';
import {
assignAssetMutation,
repairAssetMutation,
returnAssetMutation,
transferAssetMutation
} from '../api/mutations';
import {
assignmentSchema,
repairSchema,
returnSchema,
transferSchema,
type AssignmentFormValues,
type RepairFormValues,
type ReturnFormValues,
type TransferFormValues
} from '../schemas/asset';
type AssetAction = 'assign' | 'transfer' | 'return' | 'repair';
export function AssetActionForm({ action, assetId }: { action: AssetAction; assetId: string }) {
if (action === 'assign') {
return <AssignAssetForm assetId={assetId} />;
}
if (action === 'transfer') {
return <TransferAssetForm assetId={assetId} />;
}
if (action === 'return') {
return <ReturnAssetForm assetId={assetId} />;
}
return <RepairAssetForm assetId={assetId} />;
}
function AssignAssetForm({ assetId }: { assetId: string }) {
const router = useRouter();
const { data: assetData } = useSuspenseQuery(assetByIdQueryOptions(assetId));
const { data: options } = useSuspenseQuery(assetOptionsQueryOptions());
const mutation = useMutation({
...assignAssetMutation,
onSuccess: () => {
toast.success('Asset assigned successfully');
router.push(`/dashboard/assets/${assetId}`);
}
});
const form = useAppForm({
defaultValues: {
employeeId: assetData.asset.currentEmployeeId ?? '',
departmentId: assetData.asset.departmentId ?? '',
locationId: assetData.asset.locationId ?? '',
siteId: assetData.asset.siteId ?? '',
assignDate: new Date().toISOString().slice(0, 10),
documentAttachment: '',
reason: ''
} as AssignmentFormValues,
validators: { onSubmit: assignmentSchema },
onSubmit: async ({ value }) => {
await mutation.mutateAsync({
id: assetId,
values: {
...value,
departmentId: value.departmentId || null,
locationId: value.locationId || null,
siteId: value.siteId || null
}
});
}
});
const { FormSelectField, FormTextField, FormDatePickerField } =
useFormFields<AssignmentFormValues>();
return (
<AssetActionLayout actionTitle='Assign Asset' assetCode={assetData.asset.assetCode}>
<form.AppForm>
<form.Form className='space-y-6'>
<FormSelectField
name='employeeId'
label='Employee'
required
options={options.employees.map((item) => ({
value: item.id,
label: `${item.employeeNo} - ${item.name}`
}))}
/>
<FormSelectField
name='departmentId'
label='Department'
options={options.departments.map((item) => ({
value: item.id,
label: `${item.code} - ${item.name}`
}))}
/>
<FormSelectField
name='siteId'
label='Site'
options={options.sites.map((item) => ({ value: item.id, label: item.name }))}
/>
<FormSelectField
name='locationId'
label='Location'
options={options.locations.map((item) => ({ value: item.id, label: item.name }))}
/>
<FormDatePickerField name='assignDate' label='Assign Date' required />
<FormTextField name='documentAttachment' label='Document Attachment' required />
<FormTextField name='reason' label='Reason' />
<ActionButtons onCancel={() => router.back()} />
</form.Form>
</form.AppForm>
</AssetActionLayout>
);
}
function TransferAssetForm({ assetId }: { assetId: string }) {
const router = useRouter();
const { data: assetData } = useSuspenseQuery(assetByIdQueryOptions(assetId));
const { data: options } = useSuspenseQuery(assetOptionsQueryOptions());
const mutation = useMutation({
...transferAssetMutation,
onSuccess: () => {
toast.success('Asset transferred successfully');
router.push(`/dashboard/assets/${assetId}`);
}
});
const form = useAppForm({
defaultValues: {
newEmployeeId: assetData.asset.currentEmployeeId ?? '',
newDepartmentId: assetData.asset.departmentId ?? '',
newLocationId: assetData.asset.locationId ?? '',
newSiteId: assetData.asset.siteId ?? '',
newAssetCode: assetData.asset.assetCode,
transferDate: new Date().toISOString().slice(0, 10),
referenceDocument: '',
reason: ''
} as TransferFormValues,
validators: { onSubmit: transferSchema },
onSubmit: async ({ value }) => {
await mutation.mutateAsync({
id: assetId,
values: {
...value,
newEmployeeId: value.newEmployeeId || null,
newDepartmentId: value.newDepartmentId || null,
newLocationId: value.newLocationId || null,
newSiteId: value.newSiteId || null
}
});
}
});
const { FormSelectField, FormTextField, FormDatePickerField } =
useFormFields<TransferFormValues>();
return (
<AssetActionLayout actionTitle='Transfer Asset' assetCode={assetData.asset.assetCode}>
<form.AppForm>
<form.Form className='space-y-6'>
<FormSelectField
name='newEmployeeId'
label='New Employee'
options={options.employees.map((item) => ({
value: item.id,
label: `${item.employeeNo} - ${item.name}`
}))}
/>
<FormSelectField
name='newDepartmentId'
label='New Department'
options={options.departments.map((item) => ({
value: item.id,
label: `${item.code} - ${item.name}`
}))}
/>
<FormSelectField
name='newSiteId'
label='New Site'
options={options.sites.map((item) => ({ value: item.id, label: item.name }))}
/>
<FormSelectField
name='newLocationId'
label='New Location'
options={options.locations.map((item) => ({ value: item.id, label: item.name }))}
/>
<FormTextField name='newAssetCode' label='New Asset Code' />
<FormDatePickerField name='transferDate' label='Transfer Date' />
<FormTextField name='referenceDocument' label='Reference Document' required />
<FormTextField name='reason' label='Transfer Reason' />
<ActionButtons onCancel={() => router.back()} />
</form.Form>
</form.AppForm>
</AssetActionLayout>
);
}
function ReturnAssetForm({ assetId }: { assetId: string }) {
const router = useRouter();
const { data: assetData } = useSuspenseQuery(assetByIdQueryOptions(assetId));
const mutation = useMutation({
...returnAssetMutation,
onSuccess: () => {
toast.success('Asset returned successfully');
router.push(`/dashboard/assets/${assetId}`);
}
});
const form = useAppForm({
defaultValues: {
returnDate: new Date().toISOString().slice(0, 10),
assetCondition: assetData.asset.assetCondition,
remark: ''
} as ReturnFormValues,
validators: { onSubmit: returnSchema },
onSubmit: async ({ value }) => {
await mutation.mutateAsync({ id: assetId, values: value });
}
});
const { FormSelectField, FormTextField, FormDatePickerField } =
useFormFields<ReturnFormValues>();
return (
<AssetActionLayout actionTitle='Return Asset' assetCode={assetData.asset.assetCode}>
<form.AppForm>
<form.Form className='space-y-6'>
<FormDatePickerField name='returnDate' label='Return Date' />
<FormSelectField
name='assetCondition'
label='Asset Condition'
required
options={assetConditionOptions.map((option) => ({
value: option.value,
label: option.label
}))}
/>
<FormTextField name='remark' label='Remark' required />
<ActionButtons onCancel={() => router.back()} />
</form.Form>
</form.AppForm>
</AssetActionLayout>
);
}
function RepairAssetForm({ assetId }: { assetId: string }) {
const router = useRouter();
const { data: assetData } = useSuspenseQuery(assetByIdQueryOptions(assetId));
const mutation = useMutation({
...repairAssetMutation,
onSuccess: () => {
toast.success('Asset repair recorded successfully');
router.push(`/dashboard/assets/${assetId}`);
}
});
const form = useAppForm({
defaultValues: {
repairDate: new Date().toISOString().slice(0, 10),
vendor: '',
problem: '',
resolution: '',
cost: null,
markAsRepair: true
} as RepairFormValues,
validators: { onSubmit: repairSchema },
onSubmit: async ({ value }) => {
await mutation.mutateAsync({ id: assetId, values: value });
}
});
const { FormCheckboxField, FormTextField, FormTextareaField, FormDatePickerField } =
useFormFields<RepairFormValues>();
return (
<AssetActionLayout actionTitle='Repair Asset' assetCode={assetData.asset.assetCode}>
<form.AppForm>
<form.Form className='space-y-6'>
<FormDatePickerField name='repairDate' label='Repair Date' />
<FormTextField name='vendor' label='Vendor' />
<FormTextField name='cost' label='Cost' type='number' step={0.01} />
<FormTextareaField name='problem' label='Problem' required rows={3} />
<FormTextareaField name='resolution' label='Resolution' rows={3} />
<FormCheckboxField name='markAsRepair' label='Mark asset as repair' />
<ActionButtons onCancel={() => router.back()} />
</form.Form>
</form.AppForm>
</AssetActionLayout>
);
}
function AssetActionLayout({
actionTitle,
assetCode,
children
}: {
actionTitle: string;
assetCode: string;
children: React.ReactNode;
}) {
return (
<Card className='mx-auto w-full max-w-2xl'>
<CardHeader>
<CardTitle>{actionTitle}</CardTitle>
<div className='text-muted-foreground text-sm'>Asset: {assetCode}</div>
</CardHeader>
<CardContent>{children}</CardContent>
</Card>
);
}
function ActionButtons({ onCancel }: { onCancel: () => void }) {
return (
<div className='flex justify-end gap-2'>
<Button type='button' variant='outline' onClick={onCancel}>
Cancel
</Button>
<Button type='submit'>Save</Button>
</div>
);
}

View File

@@ -1,111 +0,0 @@
'use client';
import Link from 'next/link';
import { useSuspenseQuery } from '@tanstack/react-query';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { buttonVariants } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { assetByIdQueryOptions } from '../api/queries';
export function AssetDetailView({ assetId }: { assetId: string }) {
const { data } = useSuspenseQuery(assetByIdQueryOptions(assetId));
return (
<div className='space-y-6'>
<Card>
<CardHeader className='flex flex-row items-start justify-between gap-4'>
<div>
<CardTitle>{data.asset.assetName}</CardTitle>
<div className='text-muted-foreground text-sm'>
{data.asset.assetCode} {data.asset.assetUid}
</div>
</div>
<div className='flex gap-2'>
<Link href={`/dashboard/assets/${assetId}/edit`} className={cn(buttonVariants({ variant: 'outline' }))}>
Edit
</Link>
<Link href={`/dashboard/assets/${assetId}/assign`} className={cn(buttonVariants())}>
Assign
</Link>
</div>
</CardHeader>
<CardContent className='grid gap-4 md:grid-cols-2 xl:grid-cols-3'>
<Detail label='Asset Name' value={data.asset.assetName} />
<Detail label='Category' value={data.asset.assetCategory} />
<Detail label='Company' value={data.asset.companyName ?? 'N/A'} />
<Detail label='Type' value={data.asset.assetType} />
<Detail label='Asset Status' value={<Badge variant='outline'>{data.asset.status}</Badge>} />
<Detail
label='Asset Condition'
value={<Badge variant='secondary'>{data.asset.assetCondition}</Badge>}
/>
<Detail
label='Disposition / Process Status'
value={<Badge variant='secondary'>{data.asset.dispositionStatus}</Badge>}
/>
<Detail label='Current User' value={data.asset.currentEmployeeName ?? 'Unassigned'} />
<Detail label='Department' value={data.asset.departmentName ?? 'N/A'} />
<Detail label='Location' value={data.asset.locationName ?? 'N/A'} />
<Detail label='Serial Number' value={data.asset.serialNumber ?? 'N/A'} />
<Detail label='Warranty Expiry' value={data.asset.warrantyExpiryDate?.slice(0, 10) ?? 'N/A'} />
<Detail label='Custodian Team' value={data.asset.custodianTeam ?? 'N/A'} />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Movement History</CardTitle>
</CardHeader>
<CardContent className='space-y-3'>
{data.movements.length ? (
data.movements.map((movement) => (
<div key={movement.id} className='rounded-lg border p-3'>
<div className='flex items-center justify-between gap-4'>
<div className='font-medium'>{movement.eventType}</div>
<div className='text-muted-foreground text-sm'>{movement.eventDate.slice(0, 10)}</div>
</div>
<div className='text-muted-foreground mt-1 text-sm'>
{movement.reason ?? 'No reason provided'}
</div>
</div>
))
) : (
<div className='text-muted-foreground text-sm'>No movements recorded yet.</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Repair History</CardTitle>
</CardHeader>
<CardContent className='space-y-3'>
{data.repairs.length ? (
data.repairs.map((repair) => (
<div key={repair.id} className='rounded-lg border p-3'>
<div className='flex items-center justify-between gap-4'>
<div className='font-medium'>{repair.vendor ?? 'Internal'}</div>
<div className='text-muted-foreground text-sm'>{repair.repairDate.slice(0, 10)}</div>
</div>
<div className='mt-1 text-sm'>{repair.problem}</div>
<div className='text-muted-foreground text-sm'>{repair.resolution ?? 'Pending resolution'}</div>
</div>
))
) : (
<div className='text-muted-foreground text-sm'>No repair records yet.</div>
)}
</CardContent>
</Card>
</div>
);
}
function Detail({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className='space-y-1'>
<div className='text-muted-foreground text-sm'>{label}</div>
<div className='font-medium'>{value}</div>
</div>
);
}

View File

@@ -1,221 +0,0 @@
'use client';
import { useSuspenseQuery, useMutation } from '@tanstack/react-query';
import { useRouter } from 'next/navigation';
import { toast } from 'sonner';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { useAppForm, useFormFields } from '@/components/ui/tanstack-form';
import { createAssetMutation, updateAssetMutation } from '../api/mutations';
import { assetOptionsQueryOptions } from '../api/queries';
import type { Asset } from '../api/types';
import { assetSchema, type AssetFormValues } from '../schemas/asset';
import {
assetConditionOptions,
assetStatusOptions,
assetTypeOptions,
dispositionStatusOptions
} from '../constants';
export default function AssetForm({
initialData,
pageTitle
}: {
initialData: Asset | null;
pageTitle: string;
}) {
const router = useRouter();
const isEdit = !!initialData;
const { data: options } = useSuspenseQuery(assetOptionsQueryOptions());
const createMutation = useMutation({
...createAssetMutation,
onSuccess: () => {
toast.success('Asset created successfully');
router.push('/dashboard/assets');
},
onError: (error) => toast.error(error instanceof Error ? error.message : 'Failed to create asset')
});
const updateMutation = useMutation({
...updateAssetMutation,
onSuccess: () => {
toast.success('Asset updated successfully');
router.push('/dashboard/assets');
},
onError: (error) => toast.error(error instanceof Error ? error.message : 'Failed to update asset')
});
const form = useAppForm({
defaultValues: {
assetCode: initialData?.assetCode ?? '',
assetName: initialData?.assetName ?? '',
assetType: initialData?.assetType ?? 'hardware',
assetCategory: initialData?.assetCategory ?? '',
brand: initialData?.brand ?? '',
model: initialData?.model ?? '',
specification: initialData?.specification ?? '',
serialNumber: initialData?.serialNumber ?? '',
siteId: initialData?.siteId ?? '',
departmentId: initialData?.departmentId ?? '',
locationId: initialData?.locationId ?? '',
currentEmployeeId: initialData?.currentEmployeeId ?? '',
custodianTeam: initialData?.custodianTeam ?? '',
purchaseDate: initialData?.purchaseDate?.slice(0, 10) ?? '',
warrantyExpiryDate: initialData?.warrantyExpiryDate?.slice(0, 10) ?? '',
eosDate: initialData?.eosDate?.slice(0, 10) ?? '',
eolDate: initialData?.eolDate?.slice(0, 10) ?? '',
eopDate: initialData?.eopDate?.slice(0, 10) ?? '',
status: initialData?.status ?? 'AVAILABLE',
assetCondition: initialData?.assetCondition ?? 'NORMAL',
dispositionStatus: initialData?.dispositionStatus ?? 'NONE',
notes: initialData?.notes ?? ''
} as AssetFormValues,
validators: {
onSubmit: assetSchema
},
onSubmit: async ({ value }) => {
const payload = {
...value,
siteId: value.siteId || null,
departmentId: value.departmentId || null,
locationId: value.locationId || null,
currentEmployeeId: value.currentEmployeeId || null
};
if (isEdit) {
await updateMutation.mutateAsync({ id: initialData.id, values: payload });
} else {
await createMutation.mutateAsync(payload);
}
}
});
const { FormTextField, FormSelectField, FormTextareaField, FormDatePickerField } =
useFormFields<AssetFormValues>();
return (
<Card className='mx-auto w-full'>
<CardHeader>
<CardTitle className='text-left text-2xl font-bold'>{pageTitle}</CardTitle>
</CardHeader>
<CardContent>
<form.AppForm>
<form.Form className='space-y-6'>
<div className='grid grid-cols-1 gap-6 md:grid-cols-2'>
<FormTextField name='assetCode' label='Asset Code' required placeholder='NBK-001' />
<FormTextField
name='assetName'
label='Asset Name'
required
placeholder='Dell Latitude 5450'
/>
<FormSelectField
name='assetType'
label='Asset Type'
required
options={assetTypeOptions.map((option) => ({
value: option.value,
label: option.label
}))}
/>
<FormTextField
name='assetCategory'
label='Asset Category'
required
placeholder='Notebook'
/>
<FormSelectField
name='status'
label='Asset Status'
required
options={assetStatusOptions.map((option) => ({
value: option.value,
label: option.label
}))}
/>
<FormSelectField
name='assetCondition'
label='Asset Condition'
required
options={assetConditionOptions.map((option) => ({
value: option.value,
label: option.label
}))}
/>
<FormSelectField
name='dispositionStatus'
label='Disposition / Process Status'
required
options={dispositionStatusOptions.map((option) => ({
value: option.value,
label: option.label
}))}
/>
<FormTextField name='brand' label='Brand' placeholder='Dell' />
<FormTextField name='model' label='Model' placeholder='Latitude 5450' />
<FormTextField name='serialNumber' label='Serial Number' placeholder='SN-12345' />
<FormTextField name='custodianTeam' label='Custodian Team' placeholder='Helpdesk' />
<FormSelectField
name='siteId'
label='Site'
options={options.sites.map((item) => ({ value: item.id, label: `${item.code} - ${item.name}` }))}
placeholder='Select site'
/>
<FormSelectField
name='departmentId'
label='Department'
options={options.departments.map((item) => ({
value: item.id,
label: `${item.code} - ${item.name}`
}))}
placeholder='Select department'
/>
<FormSelectField
name='locationId'
label='Location'
options={options.locations.map((item) => ({
value: item.id,
label: item.name
}))}
placeholder='Select location'
/>
<FormSelectField
name='currentEmployeeId'
label='Current User'
options={options.employees.map((item) => ({
value: item.id,
label: `${item.employeeNo} - ${item.name}`
}))}
placeholder='Select employee'
/>
<FormDatePickerField name='purchaseDate' label='Purchase Date' />
<FormDatePickerField
name='warrantyExpiryDate'
label='Warranty Expiry'
/>
<FormDatePickerField name='eosDate' label='EOS Date' />
<FormDatePickerField name='eolDate' label='EOL Date' />
<FormDatePickerField name='eopDate' label='EOP Date' />
</div>
<FormTextareaField
name='specification'
label='Specification'
placeholder='CPU, RAM, storage or software notes'
rows={4}
/>
<FormTextareaField name='notes' label='Notes' placeholder='Additional notes' rows={4} />
<div className='flex justify-end gap-2'>
<Button type='button' variant='outline' onClick={() => router.back()}>
Back
</Button>
<form.SubmitButton>{isEdit ? 'Update Asset' : 'Create Asset'}</form.SubmitButton>
</div>
</form.Form>
</form.AppForm>
</CardContent>
</Card>
);
}

View File

@@ -1,36 +0,0 @@
'use client';
import { useSuspenseQuery } from '@tanstack/react-query';
import { Card, CardContent } from '@/components/ui/card';
import { assetHistoryQueryOptions } from '../api/queries';
export function AssetHistoryView() {
const { data } = useSuspenseQuery(assetHistoryQueryOptions());
return (
<div className='space-y-4'>
{data.movements.length ? (
data.movements.map((movement) => (
<Card key={movement.id}>
<CardContent className='space-y-1 p-4'>
<div className='flex items-center justify-between gap-4'>
<div className='font-medium'>
{movement.eventType} - {movement.assetCode}
</div>
<div className='text-muted-foreground text-sm'>{movement.eventDate.slice(0, 10)}</div>
</div>
<div className='text-muted-foreground text-sm'>
{movement.assetUid} {movement.assetName}
</div>
<div className='text-sm'>{movement.reason ?? 'No reason provided'}</div>
</CardContent>
</Card>
))
) : (
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
No asset history found.
</div>
)}
</div>
);
}

View File

@@ -1,32 +0,0 @@
import { HydrationBoundary, dehydrate } from "@tanstack/react-query";
import { getQueryClient } from "@/lib/query-client";
import { searchParamsCache } from "@/lib/searchparams";
import { assetsQueryOptions } from "../api/queries";
import { AssetsTable } from "./assets-table";
export default function AssetListingPage() {
const page = searchParamsCache.get("page");
const search = searchParamsCache.get("name");
const pageLimit = searchParamsCache.get("perPage");
const status = searchParamsCache.get("status");
const assetType = searchParamsCache.get("assetType");
const sort = searchParamsCache.get("sort");
const filters = {
page,
limit: pageLimit,
...(search && { search }),
...(status && { status }),
...(assetType && { assetType }),
...(sort && { sort }),
};
const queryClient = getQueryClient();
void queryClient.prefetchQuery(assetsQueryOptions(filters));
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<AssetsTable />
</HydrationBoundary>
);
}

View File

@@ -1,33 +0,0 @@
'use client';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { useSuspenseQuery } from '@tanstack/react-query';
import { assetSummaryQueryOptions } from '../api/queries';
export function AssetSummaryCards() {
const { data } = useSuspenseQuery(assetSummaryQueryOptions());
const cards = [
{ title: 'Total Assets', value: data.cards.totalAssets },
{ title: 'Assigned Assets', value: data.cards.assignedAssets },
{ title: 'In Stock Assets', value: data.cards.inStockAssets },
{ title: 'Transfer This Month', value: data.cards.transferThisMonth },
{ title: 'Assets Without User', value: data.cards.withoutUser },
{ title: 'Assets Without Location', value: data.cards.withoutLocation }
];
return (
<div className='grid gap-4 md:grid-cols-2 xl:grid-cols-3'>
{cards.map((card) => (
<Card key={card.title}>
<CardHeader className='pb-2'>
<CardTitle className='text-sm font-medium'>{card.title}</CardTitle>
</CardHeader>
<CardContent>
<div className='text-3xl font-semibold'>{card.value}</div>
</CardContent>
</Card>
))}
</div>
);
}

View File

@@ -1,74 +0,0 @@
'use client';
import { AlertModal } from '@/components/modal/alert-modal';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu';
import { useMutation } from '@tanstack/react-query';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { toast } from 'sonner';
import { Icons } from '@/components/icons';
import { deleteAssetMutation } from '../../api/mutations';
import type { Asset } from '../../api/types';
export function CellAction({ data }: { data: Asset }) {
const [open, setOpen] = useState(false);
const router = useRouter();
const deleteMutation = useMutation({
...deleteAssetMutation,
onSuccess: () => {
toast.success('Asset deleted successfully');
setOpen(false);
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : 'Failed to delete asset');
}
});
return (
<>
<AlertModal
isOpen={open}
onClose={() => setOpen(false)}
onConfirm={() => deleteMutation.mutate(data.id)}
loading={deleteMutation.isPending}
/>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button variant='ghost' className='h-8 w-8 p-0'>
<span className='sr-only'>Open menu</span>
<Icons.ellipsis className='h-4 w-4' />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end'>
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem onClick={() => router.push(`/dashboard/assets/${data.id}`)}>
<Icons.info className='mr-2 h-4 w-4' /> View
</DropdownMenuItem>
<DropdownMenuItem onClick={() => router.push(`/dashboard/assets/${data.id}/edit`)}>
<Icons.edit className='mr-2 h-4 w-4' /> Edit
</DropdownMenuItem>
<DropdownMenuItem onClick={() => router.push(`/dashboard/assets/${data.id}/assign`)}>
<Icons.share className='mr-2 h-4 w-4' /> Assign
</DropdownMenuItem>
<DropdownMenuItem onClick={() => router.push(`/dashboard/assets/${data.id}/transfer`)}>
<Icons.arrowRight className='mr-2 h-4 w-4' /> Transfer
</DropdownMenuItem>
<DropdownMenuItem onClick={() => router.push(`/dashboard/assets/${data.id}/return`)}>
<Icons.logout className='mr-2 h-4 w-4' /> Return
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setOpen(true)}>
<Icons.trash className='mr-2 h-4 w-4' /> Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
);
}

View File

@@ -1,128 +0,0 @@
'use client';
import { Badge } from '@/components/ui/badge';
import { DataTableColumnHeader } from '@/components/ui/table/data-table-column-header';
import { Column, type ColumnDef } from '@tanstack/react-table';
import { Icons } from '@/components/icons';
import type { Asset } from '../../api/types';
import {
assetConditionOptions,
assetStatusOptions,
dispositionStatusOptions
} from '../../constants';
import { CellAction } from './cell-action';
export const columns: ColumnDef<Asset>[] = [
{
accessorKey: 'assetCode',
header: ({ column }: { column: Column<Asset, unknown> }) => (
<DataTableColumnHeader column={column} title='Asset' />
),
cell: ({ row }) => (
<div className='flex flex-col'>
<span className='font-medium'>{row.original.assetCode}</span>
<span className='text-muted-foreground text-xs'>{row.original.assetUid}</span>
</div>
),
meta: {
label: 'Search',
placeholder: 'Search assets...',
variant: 'text' as const,
icon: Icons.search
},
enableColumnFilter: true
},
{
accessorKey: 'assetName',
header: ({ column }: { column: Column<Asset, unknown> }) => (
<DataTableColumnHeader column={column} title='Asset Name' />
),
cell: ({ row }) => (
<div className='flex flex-col'>
<span>{row.original.assetName}</span>
<span className='text-muted-foreground text-xs'>{row.original.assetCategory}</span>
</div>
)
},
{
accessorKey: 'assetCategory',
header: ({ column }: { column: Column<Asset, unknown> }) => (
<DataTableColumnHeader column={column} title='Category' />
),
cell: ({ row }) => row.original.assetCategory
},
{
accessorKey: 'companyName',
header: ({ column }: { column: Column<Asset, unknown> }) => (
<DataTableColumnHeader column={column} title='Company' />
),
cell: ({ row }) => row.original.companyName ?? <span className='text-muted-foreground'>N/A</span>
},
{
accessorKey: 'departmentName',
header: ({ column }: { column: Column<Asset, unknown> }) => (
<DataTableColumnHeader column={column} title='Department' />
),
cell: ({ row }) => row.original.departmentName ?? <span className='text-muted-foreground'>N/A</span>
},
{
accessorKey: 'status',
header: ({ column }: { column: Column<Asset, unknown> }) => (
<DataTableColumnHeader column={column} title='Asset Status' />
),
cell: ({ row }) => <Badge variant='outline'>{row.original.status}</Badge>,
enableColumnFilter: true,
meta: {
label: 'Asset Status',
variant: 'multiSelect' as const,
options: assetStatusOptions.map((option) => ({ label: option.label, value: option.value }))
}
},
{
accessorKey: 'assetCondition',
header: ({ column }: { column: Column<Asset, unknown> }) => (
<DataTableColumnHeader column={column} title='Asset Condition' />
),
cell: ({ row }) => <Badge variant='secondary'>{row.original.assetCondition}</Badge>,
enableColumnFilter: true,
meta: {
label: 'Asset Condition',
variant: 'multiSelect' as const,
options: assetConditionOptions.map((option) => ({ label: option.label, value: option.value }))
}
},
{
accessorKey: 'dispositionStatus',
header: ({ column }: { column: Column<Asset, unknown> }) => (
<DataTableColumnHeader column={column} title='Disposition / Process Status' />
),
cell: ({ row }) => <Badge variant='secondary'>{row.original.dispositionStatus}</Badge>,
enableColumnFilter: true,
meta: {
label: 'Disposition / Process Status',
variant: 'multiSelect' as const,
options: dispositionStatusOptions.map((option) => ({
label: option.label,
value: option.value
}))
}
},
{
accessorKey: 'currentEmployeeName',
header: ({ column }: { column: Column<Asset, unknown> }) => (
<DataTableColumnHeader column={column} title='User' />
),
cell: ({ row }) => row.original.currentEmployeeName ?? <span className='text-muted-foreground'>Unassigned</span>
},
{
accessorKey: 'locationName',
header: ({ column }: { column: Column<Asset, unknown> }) => (
<DataTableColumnHeader column={column} title='Location' />
),
cell: ({ row }) => row.original.locationName ?? <span className='text-muted-foreground'>N/A</span>
},
{
id: 'actions',
cell: ({ row }) => <CellAction data={row.original} />
}
];

View File

@@ -1,443 +0,0 @@
'use client';
import { useMemo, useState } from 'react';
import { useMutation, useSuspenseQuery } from '@tanstack/react-query';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { useAppForm, useFormFields } from '@/components/ui/tanstack-form';
import { assetOptionsQueryOptions, departmentQueryOptions, employeeQueryOptions, locationQueryOptions, masterDataQueryOptions } from '../api/queries';
import {
createMasterDataMutation,
deleteMasterDataMutation,
updateMasterDataMutation
} from '../api/mutations';
import { masterDataLabels } from '../constants';
import type {
Department,
Employee,
Location,
MasterDataEntity,
MasterDataMutationPayload,
Site
} from '../api/types';
import {
departmentSchema,
employeeSchema,
locationSchema,
siteSchema
} from '../schemas/master-data';
export function MasterDataManager({ entity }: { entity: MasterDataEntity }) {
const [editingId, setEditingId] = useState<string | null>(null);
const [openForm, setOpenForm] = useState(false);
const createMutation = useMutation({
...createMasterDataMutation,
onSuccess: () => {
toast.success(`${masterDataLabels[entity]} item created`);
setOpenForm(false);
setEditingId(null);
}
});
const updateMutation = useMutation({
...updateMasterDataMutation,
onSuccess: () => {
toast.success(`${masterDataLabels[entity]} item updated`);
setOpenForm(false);
setEditingId(null);
}
});
const deleteMutation = useMutation({
...deleteMasterDataMutation,
onSuccess: () => toast.success(`${masterDataLabels[entity]} item deleted`)
});
const options = useSuspenseQuery(assetOptionsQueryOptions()).data;
const items = useMasterDataItems(entity);
const editingItem = items.find((item) => item.id === editingId) ?? null;
return (
<div className='space-y-6'>
<div className='flex justify-end'>
<Button
onClick={() => {
setEditingId(null);
setOpenForm(true);
}}
>
Add {masterDataLabels[entity].slice(0, -1)}
</Button>
</div>
{openForm && (
<MasterDataEditor
entity={entity}
initialData={editingItem}
options={options}
onCancel={() => {
setOpenForm(false);
setEditingId(null);
}}
onSubmit={async (values) => {
if (editingId) {
await updateMutation.mutateAsync({ entity, id: editingId, values });
} else {
await createMutation.mutateAsync({ entity, values });
}
}}
/>
)}
<div className='grid gap-4'>
{items.map((item) => (
<Card key={item.id}>
<CardContent className='flex items-start justify-between gap-4 p-4'>
<div className='space-y-1'>
<div className='font-medium'>{describeItem(entity, item)}</div>
<div className='text-muted-foreground text-sm'>{subDescribeItem(entity, item)}</div>
</div>
<div className='flex gap-2'>
<Button
variant='outline'
onClick={() => {
setEditingId(item.id);
setOpenForm(true);
}}
>
Edit
</Button>
<Button
variant='destructive'
onClick={() => deleteMutation.mutate({ entity, id: item.id })}
isLoading={deleteMutation.isPending}
>
Delete
</Button>
</div>
</CardContent>
</Card>
))}
</div>
</div>
);
}
function useMasterDataItems(entity: MasterDataEntity) {
const sites = useSuspenseQuery(masterDataQueryOptions('sites')).data.items;
const departments = useSuspenseQuery(departmentQueryOptions('departments')).data.items;
const locations = useSuspenseQuery(locationQueryOptions('locations')).data.items;
const employees = useSuspenseQuery(employeeQueryOptions('employees')).data.items;
return useMemo(() => {
switch (entity) {
case 'sites':
return sites;
case 'departments':
return departments;
case 'locations':
return locations;
case 'employees':
return employees;
}
}, [departments, employees, entity, locations, sites]);
}
function describeItem(
entity: MasterDataEntity,
item: Site | Department | Location | Employee
) {
switch (entity) {
case 'sites':
case 'departments':
return `${(item as Site | Department).code} - ${item.name}`;
case 'locations':
return (item as Location).name;
case 'employees':
return `${(item as Employee).employeeNo} - ${item.name}`;
}
}
function subDescribeItem(
entity: MasterDataEntity,
item: Site | Department | Location | Employee
) {
switch (entity) {
case 'sites':
return 'Company site';
case 'departments':
return 'Department master';
case 'locations':
return [(item as Location).siteName, (item as Location).building, (item as Location).floor]
.filter(Boolean)
.join(' / ');
case 'employees':
return [(item as Employee).departmentName, (item as Employee).siteName, (item as Employee).status]
.filter(Boolean)
.join(' / ');
}
}
function MasterDataEditor({
entity,
initialData,
options,
onCancel,
onSubmit
}: {
entity: MasterDataEntity;
initialData: Site | Department | Location | Employee | null;
options: {
sites: Site[];
departments: Department[];
};
onCancel: () => void;
onSubmit: (values: MasterDataMutationPayload) => Promise<void>;
}) {
if (entity === 'sites') {
return (
<SiteEditor
initialData={initialData as Site | null}
onCancel={onCancel}
onSubmit={onSubmit}
/>
);
}
if (entity === 'departments') {
return (
<DepartmentEditor
initialData={initialData as Department | null}
onCancel={onCancel}
onSubmit={onSubmit}
/>
);
}
if (entity === 'locations') {
return (
<LocationEditor
initialData={initialData as Location | null}
siteOptions={options.sites.map((item) => ({ value: item.id, label: item.name }))}
onCancel={onCancel}
onSubmit={onSubmit}
/>
);
}
return (
<EmployeeEditor
initialData={initialData as Employee | null}
siteOptions={options.sites.map((item) => ({ value: item.id, label: item.name }))}
departmentOptions={options.departments.map((item) => ({ value: item.id, label: item.name }))}
onCancel={onCancel}
onSubmit={onSubmit}
/>
);
}
function SiteEditor({
initialData,
onCancel,
onSubmit
}: {
initialData: Site | null;
onCancel: () => void;
onSubmit: (values: MasterDataMutationPayload) => Promise<void>;
}) {
const form = useAppForm({
defaultValues: {
code: initialData?.code ?? '',
name: initialData?.name ?? ''
},
validators: { onSubmit: siteSchema },
onSubmit: async ({ value }) => onSubmit(value)
});
const { FormTextField } = useFormFields<{ code: string; name: string }>();
return (
<EditorCard title={initialData ? 'Edit Site' : 'New Site'} onCancel={onCancel}>
<form.AppForm>
<form.Form className='space-y-4'>
<FormTextField name='code' label='Code' required />
<FormTextField name='name' label='Name' required />
<ActionRow onCancel={onCancel} />
</form.Form>
</form.AppForm>
</EditorCard>
);
}
function DepartmentEditor({
initialData,
onCancel,
onSubmit
}: {
initialData: Department | null;
onCancel: () => void;
onSubmit: (values: MasterDataMutationPayload) => Promise<void>;
}) {
const form = useAppForm({
defaultValues: {
code: initialData?.code ?? '',
name: initialData?.name ?? ''
},
validators: { onSubmit: departmentSchema },
onSubmit: async ({ value }) => onSubmit(value)
});
const { FormTextField } = useFormFields<{ code: string; name: string }>();
return (
<EditorCard title={initialData ? 'Edit Department' : 'New Department'} onCancel={onCancel}>
<form.AppForm>
<form.Form className='space-y-4'>
<FormTextField name='code' label='Code' required />
<FormTextField name='name' label='Name' required />
<ActionRow onCancel={onCancel} />
</form.Form>
</form.AppForm>
</EditorCard>
);
}
function LocationEditor({
initialData,
siteOptions,
onCancel,
onSubmit
}: {
initialData: Location | null;
siteOptions: Array<{ value: string; label: string }>;
onCancel: () => void;
onSubmit: (values: MasterDataMutationPayload) => Promise<void>;
}) {
const form = useAppForm({
defaultValues: {
name: initialData?.name ?? '',
siteId: initialData?.siteId ?? '',
building: initialData?.building ?? '',
floor: initialData?.floor ?? '',
area: initialData?.area ?? ''
},
validators: { onSubmit: locationSchema },
onSubmit: async ({ value }) =>
onSubmit({
...value,
siteId: value.siteId || null
})
});
const { FormTextField, FormSelectField } = useFormFields<{
name: string;
siteId: string;
building: string;
floor: string;
area: string;
}>();
return (
<EditorCard title={initialData ? 'Edit Location' : 'New Location'} onCancel={onCancel}>
<form.AppForm>
<form.Form className='grid gap-4 md:grid-cols-2'>
<FormTextField name='name' label='Name' required />
<FormSelectField name='siteId' label='Site' options={siteOptions} />
<FormTextField name='building' label='Building' />
<FormTextField name='floor' label='Floor' />
<FormTextField name='area' label='Area' />
<div className='md:col-span-2'>
<ActionRow onCancel={onCancel} />
</div>
</form.Form>
</form.AppForm>
</EditorCard>
);
}
function EmployeeEditor({
initialData,
siteOptions,
departmentOptions,
onCancel,
onSubmit
}: {
initialData: Employee | null;
siteOptions: Array<{ value: string; label: string }>;
departmentOptions: Array<{ value: string; label: string }>;
onCancel: () => void;
onSubmit: (values: MasterDataMutationPayload) => Promise<void>;
}) {
const form = useAppForm({
defaultValues: {
employeeNo: initialData?.employeeNo ?? '',
name: initialData?.name ?? '',
departmentId: initialData?.departmentId ?? '',
siteId: initialData?.siteId ?? '',
status: initialData?.status ?? 'active'
},
validators: { onSubmit: employeeSchema },
onSubmit: async ({ value }) =>
onSubmit({
...value,
departmentId: value.departmentId || null,
siteId: value.siteId || null
})
});
const { FormTextField, FormSelectField } = useFormFields<{
employeeNo: string;
name: string;
departmentId: string;
siteId: string;
status: string;
}>();
return (
<EditorCard title={initialData ? 'Edit Employee' : 'New Employee'} onCancel={onCancel}>
<form.AppForm>
<form.Form className='grid gap-4 md:grid-cols-2'>
<FormTextField name='employeeNo' label='Employee No' required />
<FormTextField name='name' label='Name' required />
<FormSelectField name='departmentId' label='Department' options={departmentOptions} />
<FormSelectField name='siteId' label='Site' options={siteOptions} />
<FormTextField name='status' label='Status' required />
<div className='md:col-span-2'>
<ActionRow onCancel={onCancel} />
</div>
</form.Form>
</form.AppForm>
</EditorCard>
);
}
function EditorCard({
title,
children,
onCancel
}: {
title: string;
children: React.ReactNode;
onCancel: () => void;
}) {
return (
<Card>
<CardHeader className='flex flex-row items-center justify-between gap-4'>
<CardTitle>{title}</CardTitle>
<Button variant='outline' onClick={onCancel}>
Close
</Button>
</CardHeader>
<CardContent>{children}</CardContent>
</Card>
);
}
function ActionRow({ onCancel }: { onCancel: () => void }) {
return (
<div className='flex justify-end gap-2'>
<Button type='button' variant='outline' onClick={onCancel}>
Cancel
</Button>
<Button type='submit'>Save</Button>
</div>
);
}

View File

@@ -1,78 +0,0 @@
import type { BusinessRole, MasterDataEntity } from './api/types';
export const assetStatusValues = [
'AVAILABLE',
'ASSIGNED',
'IN_REPAIR',
'LOST',
'RETIRED'
] as const;
export const assetConditionValues = ['NORMAL', 'DAMAGED'] as const;
export const dispositionStatusValues = [
'NONE',
'WAITING_REPAIR',
'WAITING_WRITE_OFF',
'WRITE_OFF_COMPLETED'
] as const;
export const movementTypeValues = [
'CREATE',
'ASSIGN',
'TRANSFER',
'CHANGE_USER',
'CHANGE_LOCATION',
'CHANGE_DEPARTMENT',
'CHANGE_CODE',
'RETURN',
'REPAIR'
] as const;
export const assetTypeOptions = [
{ value: 'hardware', label: 'Hardware' },
{ value: 'software', label: 'Software' }
] as const;
export const assetStatusOptions = [
{ value: 'AVAILABLE', label: 'Available' },
{ value: 'ASSIGNED', label: 'Assigned' },
{ value: 'IN_REPAIR', label: 'In Repair' },
{ value: 'LOST', label: 'Lost' },
{ value: 'RETIRED', label: 'Retired' }
] as const;
export const assetConditionOptions = [
{ value: 'NORMAL', label: 'Normal' },
{ value: 'DAMAGED', label: 'Damaged' }
] as const;
export const dispositionStatusOptions = [
{ value: 'NONE', label: 'None' },
{ value: 'WAITING_REPAIR', label: 'Waiting Repair' },
{ value: 'WAITING_WRITE_OFF', label: 'Waiting Write-Off' },
{ value: 'WRITE_OFF_COMPLETED', label: 'Write-Off Completed' }
] as const;
export const businessRoleOptions: Array<{ value: BusinessRole; label: string }> = [
{ value: 'it_admin', label: 'IT Admin' },
{ value: 'helpdesk', label: 'Helpdesk' },
{ value: 'infrastructure', label: 'Infrastructure' },
{ value: 'application', label: 'Application' },
{ value: 'auditor', label: 'Auditor' },
{ value: 'viewer', label: 'Viewer' }
];
export const masterDataLabels: Record<MasterDataEntity, string> = {
sites: 'Sites',
departments: 'Departments',
locations: 'Locations',
employees: 'Employees'
};
export const assetActionLabels = {
assign: 'Assign Asset',
transfer: 'Transfer Asset',
return: 'Return Asset',
repair: 'Repair Asset'
} as const;

View File

@@ -1,77 +0,0 @@
import * as z from 'zod';
export const assetSchema = z.object({
assetCode: z.string().min(1, 'Asset code is required'),
assetName: z.string().min(1, 'Asset name is required'),
assetType: z.enum(['hardware', 'software']),
assetCategory: z.string().min(1, 'Asset category is required'),
brand: z.string().optional(),
model: z.string().optional(),
specification: z.string().optional(),
serialNumber: z.string().optional(),
siteId: z.string().optional().nullable(),
departmentId: z.string().optional().nullable(),
locationId: z.string().optional().nullable(),
currentEmployeeId: z.string().optional().nullable(),
custodianTeam: z.string().optional(),
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()
});
export type AssetFormValues = z.infer<typeof assetSchema>;
export const assignmentSchema = z.object({
employeeId: z.string().min(1, 'Employee is required'),
departmentId: z.string().optional().nullable(),
locationId: z.string().optional().nullable(),
siteId: z.string().optional().nullable(),
assignDate: z.string().min(1, 'Assign date is required'),
documentAttachment: z.string().min(1, 'Document attachment is required'),
reason: z.string().optional()
});
export type AssignmentFormValues = z.infer<typeof assignmentSchema>;
export 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(),
transferDate: z.string().min(1, 'Transfer date is required'),
referenceDocument: z.string().min(1, 'Reference document is required'),
reason: z.string().optional()
});
export type TransferFormValues = z.infer<typeof transferSchema>;
export const returnSchema = z.object({
returnDate: z.string().min(1, 'Return date is required'),
assetCondition: z.enum(['NORMAL', 'DAMAGED']),
remark: z.string().min(1, 'Remark is required')
});
export type ReturnFormValues = z.infer<typeof returnSchema>;
export const repairSchema = z.object({
repairDate: z.string().optional().nullable(),
vendor: z.string().optional(),
problem: z.string().min(1, 'Problem is required'),
resolution: z.string().optional(),
cost: z.number().nullable().optional(),
markAsRepair: z.boolean()
});
export type RepairFormValues = z.infer<typeof repairSchema>;

View File

@@ -1,27 +0,0 @@
import * as z from 'zod';
export const siteSchema = z.object({
code: z.string().min(1, 'Site code is required'),
name: z.string().min(1, 'Site name is required')
});
export const departmentSchema = z.object({
code: z.string().min(1, 'Department code is required'),
name: z.string().min(1, 'Department name is required')
});
export const locationSchema = z.object({
name: z.string().min(1, 'Location name is required'),
siteId: z.string(),
building: z.string(),
floor: z.string(),
area: z.string()
});
export const employeeSchema = z.object({
employeeNo: z.string().min(1, 'Employee number is required'),
name: z.string().min(1, 'Employee name is required'),
departmentId: z.string(),
siteId: z.string(),
status: z.string().min(1, 'Status is required')
});

View File

@@ -0,0 +1,98 @@
import { headers } from 'next/headers';
import { trAuditLogs } from '@/db/schema';
import { db } from '@/lib/db';
import { getCurrentUserContext } from '@/features/foundation/auth-context/service';
import type { AuditLogInput, AuditLogRecord } from './types';
async function resolveAuditContext(input: AuditLogInput) {
const context = await getCurrentUserContext();
const organizationId = input.organizationId ?? context?.activeOrganization?.id ?? null;
const userId = input.userId ?? context?.user.id ?? null;
if (!organizationId) {
throw new Error('organizationId is required for audit logging');
}
if (!userId) {
throw new Error('userId is required for audit logging');
}
return {
organizationId,
userId
};
}
async function resolveRequestId(inputRequestId?: string | null) {
if (inputRequestId) {
return inputRequestId;
}
try {
const requestHeaders = await headers();
return requestHeaders.get('x-request-id');
} catch {
return null;
}
}
function mapAuditRecord(row: typeof trAuditLogs.$inferSelect): AuditLogRecord {
return {
id: row.id,
organizationId: row.organizationId,
branchId: row.branchId,
userId: row.userId,
entityType: row.entityType,
entityId: row.entityId,
action: row.action,
beforeData: row.beforeData,
afterData: row.afterData,
requestId: row.requestId,
createdAt: row.createdAt.toISOString()
};
}
export async function auditAction(input: AuditLogInput) {
const { organizationId, userId } = await resolveAuditContext(input);
const requestId = await resolveRequestId(input.requestId);
const [created] = await db
.insert(trAuditLogs)
.values({
id: crypto.randomUUID(),
organizationId,
branchId: input.branchId ?? null,
userId,
entityType: input.entityType,
entityId: input.entityId,
action: input.action,
beforeData:
input.beforeData === undefined ? null : JSON.parse(JSON.stringify(input.beforeData)),
afterData: input.afterData === undefined ? null : JSON.parse(JSON.stringify(input.afterData)),
requestId
})
.returning();
return mapAuditRecord(created);
}
export async function auditCreate(input: Omit<AuditLogInput, 'action'>) {
return auditAction({
...input,
action: 'create'
});
}
export async function auditUpdate(input: Omit<AuditLogInput, 'action'>) {
return auditAction({
...input,
action: 'update'
});
}
export async function auditDelete(input: Omit<AuditLogInput, 'action'>) {
return auditAction({
...input,
action: 'delete'
});
}

View File

@@ -0,0 +1,25 @@
export interface AuditLogInput {
organizationId?: string;
branchId?: string | null;
userId?: string;
entityType: string;
entityId: string;
action: string;
beforeData?: unknown;
afterData?: unknown;
requestId?: string | null;
}
export interface AuditLogRecord {
id: string;
organizationId: string;
branchId: string | null;
userId: string;
entityType: string;
entityId: string;
action: string;
beforeData: unknown;
afterData: unknown;
requestId: string | null;
createdAt: string;
}

View File

@@ -0,0 +1,64 @@
import { auth } from '@/auth';
import type { CurrentOrganizationContext, CurrentUserContext } from './types';
import { requireSession } from '@/lib/auth/session';
export async function getCurrentUser() {
const session = await auth();
return session?.user ?? null;
}
export async function requireCurrentUser() {
const session = await requireSession();
return session.user;
}
function getActiveOrganizationFromUser(
user: NonNullable<Awaited<ReturnType<typeof getCurrentUser>>>
): CurrentOrganizationContext | null {
if (!user.activeOrganizationId) {
return null;
}
const organization = user.organizations.find((item) => item.id === user.activeOrganizationId);
if (!organization) {
return null;
}
return {
id: organization.id,
name: organization.name,
slug: organization.slug,
plan: organization.plan,
imageUrl: organization.imageUrl
};
}
export async function getCurrentUserContext(): Promise<CurrentUserContext | null> {
const user = await getCurrentUser();
if (!user) {
return null;
}
const activeOrganization = getActiveOrganizationFromUser(user);
const membership = activeOrganization
? {
userId: user.id,
organizationId: activeOrganization.id,
role: user.activeMembershipRole ?? 'user',
businessRole: user.activeBusinessRole ?? 'viewer',
permissions: user.activePermissions
}
: null;
return {
user,
activeOrganization,
membership,
permissions: user.activePermissions,
businessRole: user.activeBusinessRole ?? null
};
}

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