task-b complate
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
60
src/app/api/foundation/master-options/route.ts
Normal file
60
src/app/api/foundation/master-options/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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'>
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
|
||||
51
src/app/dashboard/product/page.tsx
Normal file
51
src/app/dashboard/product/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 />}>
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import OverViewPage from '@/features/overview/components/overview';
|
||||
|
||||
export const metadata = {
|
||||
title: 'Example Dashboard: Overview'
|
||||
};
|
||||
|
||||
export default function ExampleOverviewPage() {
|
||||
return <OverViewPage />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function ExampleDashboardIndexPage() {
|
||||
redirect('/example/dashboard/overview');
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import { SetupWizard } from '@/features/setup/components/setup-wizard';
|
||||
|
||||
export const metadata = {
|
||||
title: 'Setup Wizard'
|
||||
};
|
||||
|
||||
export default function SetupPage() {
|
||||
return <SetupWizard />;
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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",
|
||||
// },
|
||||
// ],
|
||||
// },
|
||||
// ],
|
||||
// },
|
||||
];
|
||||
|
||||
168
src/db/schema.ts
168
src/db/schema.ts
@@ -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()
|
||||
});
|
||||
|
||||
@@ -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
|
||||
});
|
||||
@@ -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)
|
||||
});
|
||||
}
|
||||
@@ -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'
|
||||
});
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
}
|
||||
];
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>;
|
||||
@@ -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')
|
||||
});
|
||||
98
src/features/foundation/audit-log/service.ts
Normal file
98
src/features/foundation/audit-log/service.ts
Normal 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'
|
||||
});
|
||||
}
|
||||
25
src/features/foundation/audit-log/types.ts
Normal file
25
src/features/foundation/audit-log/types.ts
Normal 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;
|
||||
}
|
||||
64
src/features/foundation/auth-context/service.ts
Normal file
64
src/features/foundation/auth-context/service.ts
Normal 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
Reference in New Issue
Block a user