task-b complate

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,77 +0,0 @@
import * as z from 'zod';
export const assetSchema = z.object({
assetCode: z.string().min(1, 'Asset code is required'),
assetName: z.string().min(1, 'Asset name is required'),
assetType: z.enum(['hardware', 'software']),
assetCategory: z.string().min(1, 'Asset category is required'),
brand: z.string().optional(),
model: z.string().optional(),
specification: z.string().optional(),
serialNumber: z.string().optional(),
siteId: z.string().optional().nullable(),
departmentId: z.string().optional().nullable(),
locationId: z.string().optional().nullable(),
currentEmployeeId: z.string().optional().nullable(),
custodianTeam: z.string().optional(),
purchaseDate: z.string().optional().nullable(),
warrantyExpiryDate: z.string().optional().nullable(),
eosDate: z.string().optional().nullable(),
eolDate: z.string().optional().nullable(),
eopDate: z.string().optional().nullable(),
status: z.enum(['AVAILABLE', 'ASSIGNED', 'IN_REPAIR', 'LOST', 'RETIRED']),
assetCondition: z.enum(['NORMAL', 'DAMAGED']),
dispositionStatus: z.enum([
'NONE',
'WAITING_REPAIR',
'WAITING_WRITE_OFF',
'WRITE_OFF_COMPLETED'
]),
notes: z.string().optional()
});
export type AssetFormValues = z.infer<typeof assetSchema>;
export const assignmentSchema = z.object({
employeeId: z.string().min(1, 'Employee is required'),
departmentId: z.string().optional().nullable(),
locationId: z.string().optional().nullable(),
siteId: z.string().optional().nullable(),
assignDate: z.string().min(1, 'Assign date is required'),
documentAttachment: z.string().min(1, 'Document attachment is required'),
reason: z.string().optional()
});
export type AssignmentFormValues = z.infer<typeof assignmentSchema>;
export const transferSchema = z.object({
newEmployeeId: z.string().optional().nullable(),
newDepartmentId: z.string().optional().nullable(),
newLocationId: z.string().optional().nullable(),
newSiteId: z.string().optional().nullable(),
newAssetCode: z.string().optional(),
transferDate: z.string().min(1, 'Transfer date is required'),
referenceDocument: z.string().min(1, 'Reference document is required'),
reason: z.string().optional()
});
export type TransferFormValues = z.infer<typeof transferSchema>;
export const returnSchema = z.object({
returnDate: z.string().min(1, 'Return date is required'),
assetCondition: z.enum(['NORMAL', 'DAMAGED']),
remark: z.string().min(1, 'Remark is required')
});
export type ReturnFormValues = z.infer<typeof returnSchema>;
export const repairSchema = z.object({
repairDate: z.string().optional().nullable(),
vendor: z.string().optional(),
problem: z.string().min(1, 'Problem is required'),
resolution: z.string().optional(),
cost: z.number().nullable().optional(),
markAsRepair: z.boolean()
});
export type RepairFormValues = z.infer<typeof repairSchema>;

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,25 @@
import type { Session } from 'next-auth';
export interface CurrentUserMembershipContext {
userId: string;
organizationId: string;
role: string;
businessRole: string;
permissions: string[];
}
export interface CurrentOrganizationContext {
id: string;
name: string;
slug: string;
plan: string;
imageUrl: string | null;
}
export interface CurrentUserContext {
user: Session['user'];
activeOrganization: CurrentOrganizationContext | null;
membership: CurrentUserMembershipContext | null;
permissions: string[];
businessRole: string | null;
}

View File

@@ -0,0 +1,45 @@
import { AuthError } from '@/lib/auth/session';
import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service';
import type { FoundationBranch } from './types';
function mapBranchOption(
option: Awaited<ReturnType<typeof getActiveOptionsByCategory>>[number]
): FoundationBranch {
return {
id: option.id,
code: option.code,
name: option.label,
value: option.value
};
}
export async function getUserBranches(): Promise<FoundationBranch[]> {
const options = await getActiveOptionsByCategory('crm_branch');
return options.map(mapBranchOption);
}
export async function getActiveBranch(): Promise<FoundationBranch | null> {
const branches = await getUserBranches();
if (branches.length === 1) {
return branches[0];
}
return null;
}
export async function validateBranchAccess(branchId?: string | null) {
if (!branchId) {
return getActiveBranch();
}
const branches = await getUserBranches();
const branch = branches.find((item) => item.id === branchId);
if (!branch) {
throw new AuthError('Branch access denied', 403);
}
return branch;
}

View File

@@ -0,0 +1,6 @@
export interface FoundationBranch {
id: string;
code: string;
name: string;
value: string | null;
}

View File

@@ -0,0 +1,166 @@
import { and, eq, sql } from 'drizzle-orm';
import { documentSequences } from '@/db/schema';
import { db } from '@/lib/db';
import { getCurrentOrganization } from '@/features/foundation/organization-context/service';
import type { DocumentSequenceInput, DocumentSequenceResult } from './types';
const DEFAULT_DOCUMENT_PREFIXES: Record<string, string> = {
customer: 'CUS',
contact: 'CON',
enquiry: 'ENQ',
quotation: 'QT',
approval: 'APV'
};
function getCurrentPeriod(date = new Date()) {
const year = String(date.getFullYear()).slice(-2);
const month = String(date.getMonth() + 1).padStart(2, '0');
return `${year}${month}`;
}
function buildDocumentCode(
prefix: string,
period: string,
nextNumber: number,
paddingLength: number
) {
return `${prefix}${period}-${String(nextNumber).padStart(paddingLength, '0')}`;
}
async function resolveOrganizationId(organizationId?: string) {
if (organizationId) {
return organizationId;
}
const organization = await getCurrentOrganization();
if (!organization) {
throw new Error('Active organization is required');
}
return organization.id;
}
async function ensureSequence(
organizationId: string,
documentType: string,
period: string,
branchId: string
) {
const existing = await db.query.documentSequences.findFirst({
where: and(
eq(documentSequences.organizationId, organizationId),
eq(documentSequences.documentType, documentType),
eq(documentSequences.period, period),
eq(documentSequences.branchId, branchId)
)
});
if (existing) {
return existing;
}
const prefix = DEFAULT_DOCUMENT_PREFIXES[documentType] ?? documentType.slice(0, 3).toUpperCase();
const [created] = await db
.insert(documentSequences)
.values({
id: crypto.randomUUID(),
organizationId,
branchId,
documentType,
period,
prefix,
currentNumber: 0,
paddingLength: 3,
isActive: true
})
.returning();
return created;
}
function toSequenceResult(row: typeof documentSequences.$inferSelect): DocumentSequenceResult {
const nextNumber = row.currentNumber + 1;
return {
code: buildDocumentCode(row.prefix, row.period, nextNumber, row.paddingLength),
documentType: row.documentType,
branchId: row.branchId || null,
currentNumber: row.currentNumber,
nextNumber,
period: row.period,
prefix: row.prefix
};
}
export async function previewNextDocumentCode(
input: DocumentSequenceInput
): Promise<DocumentSequenceResult> {
const organizationId = await resolveOrganizationId(input.organizationId);
const period = input.period ?? getCurrentPeriod();
const branchId = input.branchId ?? '';
const sequence = await ensureSequence(organizationId, input.documentType, period, branchId);
return toSequenceResult(sequence);
}
export async function generateNextDocumentCode(
input: DocumentSequenceInput
): Promise<DocumentSequenceResult> {
const organizationId = await resolveOrganizationId(input.organizationId);
const period = input.period ?? getCurrentPeriod();
const branchId = input.branchId ?? '';
await ensureSequence(organizationId, input.documentType, period, branchId);
return db.transaction(async (tx) => {
await tx.execute(sql`
select id
from document_sequences
where organization_id = ${organizationId}
and document_type = ${input.documentType}
and period = ${period}
and branch_id = ${branchId}
for update
`);
const sequence = await tx.query.documentSequences.findFirst({
where: and(
eq(documentSequences.organizationId, organizationId),
eq(documentSequences.documentType, input.documentType),
eq(documentSequences.period, period),
eq(documentSequences.branchId, branchId)
)
});
if (!sequence) {
throw new Error('Document sequence not found');
}
const [updated] = await tx
.update(documentSequences)
.set({
currentNumber: sequence.currentNumber + 1,
updatedAt: new Date()
})
.where(eq(documentSequences.id, sequence.id))
.returning();
return {
code: buildDocumentCode(
updated.prefix,
updated.period,
updated.currentNumber,
updated.paddingLength
),
documentType: updated.documentType,
branchId: updated.branchId || null,
currentNumber: updated.currentNumber,
nextNumber: updated.currentNumber + 1,
period: updated.period,
prefix: updated.prefix
};
});
}

View File

@@ -0,0 +1,16 @@
export interface DocumentSequenceInput {
documentType: string;
branchId?: string | null;
organizationId?: string;
period?: string;
}
export interface DocumentSequenceResult {
code: string;
documentType: string;
branchId: string | null;
currentNumber: number;
nextNumber: number;
period: string;
prefix: string;
}

View File

@@ -0,0 +1 @@
export {};

View File

@@ -0,0 +1,14 @@
import { queryOptions } from '@tanstack/react-query';
import { getMasterOptions } from './service';
import type { MasterOptionsFilters } from './types';
export const masterOptionKeys = {
all: ['foundation', 'master-options'] as const,
list: (filters: MasterOptionsFilters) => [...masterOptionKeys.all, 'list', filters] as const
};
export const masterOptionsQueryOptions = (filters: MasterOptionsFilters) =>
queryOptions({
queryKey: masterOptionKeys.list(filters),
queryFn: () => getMasterOptions(filters)
});

View File

@@ -0,0 +1,18 @@
import { apiClient } from '@/lib/api-client';
import type { MasterOptionsFilters, MasterOptionsResponse } from './types';
export async function getMasterOptions(
filters: MasterOptionsFilters
): Promise<MasterOptionsResponse> {
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.category) searchParams.set('category', filters.category);
if (filters.sort) searchParams.set('sort', filters.sort);
const query = searchParams.toString();
return apiClient<MasterOptionsResponse>(`/foundation/master-options${query ? `?${query}` : ''}`);
}

View File

@@ -0,0 +1,32 @@
export interface MasterOptionItem {
id: string;
organization_id: string;
category: string;
code: string;
label: string;
value: string | null;
parent_id: string | null;
parent_label: string | null;
sort_order: number;
is_active: boolean;
deleted_at: string | null;
updated_at: string;
}
export interface MasterOptionsFilters {
page?: number;
limit?: number;
search?: string;
category?: string;
sort?: string;
}
export interface MasterOptionsResponse {
success: boolean;
time: string;
message: string;
total_items: number;
offset: number;
limit: number;
items: MasterOptionItem[];
}

View File

@@ -0,0 +1,60 @@
'use client';
import type { ColumnDef } from '@tanstack/react-table';
import { DataTableColumnHeader } from '@/components/ui/table/data-table-column-header';
import { Badge } from '@/components/ui/badge';
import { CRM_MASTER_OPTION_CATEGORIES } from '@/features/foundation/master-options/types';
import type { MasterOptionItem } from '../api/types';
const categoryOptions = CRM_MASTER_OPTION_CATEGORIES.map((category) => ({
label: category,
value: category
}));
export const columns: ColumnDef<MasterOptionItem>[] = [
{
id: 'category',
accessorKey: 'category',
header: ({ column }) => <DataTableColumnHeader column={column} title='Category' />,
meta: {
label: 'Category',
variant: 'select',
options: categoryOptions
},
enableColumnFilter: true
},
{
id: 'code',
accessorKey: 'code',
header: ({ column }) => <DataTableColumnHeader column={column} title='Code' />
},
{
id: 'name',
accessorKey: 'label',
header: ({ column }) => <DataTableColumnHeader column={column} title='Label' />,
meta: {
label: 'Label',
variant: 'text',
placeholder: 'Search options...'
},
enableColumnFilter: true
},
{
id: 'parent_label',
accessorKey: 'parent_label',
header: ({ column }) => <DataTableColumnHeader column={column} title='Parent' />,
cell: ({ row }) => row.original.parent_label ?? '-'
},
{
id: 'sort_order',
accessorKey: 'sort_order',
header: ({ column }) => <DataTableColumnHeader column={column} title='Sort' />
},
{
id: 'is_active',
accessorKey: 'is_active',
header: ({ column }) => <DataTableColumnHeader column={column} title='Status' />,
cell: ({ row }) =>
row.original.is_active ? <Badge>Active</Badge> : <Badge variant='outline'>Inactive</Badge>
}
];

View File

@@ -0,0 +1,31 @@
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import { getQueryClient } from '@/lib/query-client';
import { searchParamsCache } from '@/lib/searchparams';
import { masterOptionsQueryOptions } from '../api/queries';
import { MasterOptionsTable } from './master-options-table';
export default function MasterOptionsListingPage() {
const page = searchParamsCache.get('page');
const search = searchParamsCache.get('name');
const pageLimit = searchParamsCache.get('perPage');
const category = searchParamsCache.get('category');
const sort = searchParamsCache.get('sort');
const filters = {
page,
limit: pageLimit,
...(search && { search }),
...(category && { category }),
...(sort && { sort })
};
const queryClient = getQueryClient();
void queryClient.prefetchQuery(masterOptionsQueryOptions(filters));
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<MasterOptionsTable />
</HydrationBoundary>
);
}

View File

@@ -1,25 +1,22 @@
'use client';
import { useSuspenseQuery } from '@tanstack/react-query';
import { parseAsInteger, parseAsString, useQueryStates } from 'nuqs';
import { DataTable } from '@/components/ui/table/data-table';
import { DataTableToolbar } from '@/components/ui/table/data-table-toolbar';
import { useDataTable } from '@/hooks/use-data-table';
import { getSortingStateParser } from '@/lib/parsers';
import { useSuspenseQuery } from '@tanstack/react-query';
import { parseAsInteger, parseAsString, useQueryStates } from 'nuqs';
import { assetsQueryOptions } from '../../api/queries';
import { masterOptionsQueryOptions } from '../api/queries';
import { columns } from './columns';
const columnIds = columns.map((c) => c.id).filter(Boolean) as string[];
const columnIds = columns.map((column) => column.id).filter(Boolean) as string[];
export function AssetsTable() {
export function MasterOptionsTable() {
const [params] = useQueryStates({
page: parseAsInteger.withDefault(1),
perPage: parseAsInteger.withDefault(10),
name: parseAsString,
status: parseAsString,
assetCondition: parseAsString,
dispositionStatus: parseAsString,
assetType: parseAsString,
category: parseAsString,
sort: getSortingStateParser(columnIds).withDefault([])
});
@@ -27,25 +24,18 @@ export function AssetsTable() {
page: params.page,
limit: params.perPage,
...(params.name && { search: params.name }),
...(params.status && { status: params.status }),
...(params.assetCondition && { assetCondition: params.assetCondition }),
...(params.dispositionStatus && { dispositionStatus: params.dispositionStatus }),
...(params.assetType && { assetType: params.assetType }),
...(params.category && { category: params.category }),
...(params.sort.length > 0 && { sort: JSON.stringify(params.sort) })
};
const { data } = useSuspenseQuery(assetsQueryOptions(filters));
const pageCount = Math.ceil(data.total_assets / params.perPage);
const { data } = useSuspenseQuery(masterOptionsQueryOptions(filters));
const pageCount = Math.ceil(data.total_items / params.perPage);
const { table } = useDataTable({
data: data.assets,
data: data.items,
columns,
pageCount,
shallow: true,
debounceMs: 500,
initialState: {
columnPinning: { right: ['actions'] }
}
debounceMs: 500
});
return (

View File

@@ -0,0 +1,202 @@
import { and, asc, count, desc, eq, inArray, ilike, isNull, or, type SQL } from 'drizzle-orm';
import { msOptions } from '@/db/schema';
import { db } from '@/lib/db';
import { getCurrentOrganization } from '@/features/foundation/organization-context/service';
import type {
MasterOptionListFilters,
MasterOptionListResult,
MasterOptionNode,
MasterOptionRecord
} from './types';
async function resolveOrganizationId(organizationId?: string) {
if (organizationId) {
return organizationId;
}
const organization = await getCurrentOrganization();
if (!organization) {
throw new Error('Active organization is required');
}
return organization.id;
}
function parseSort(sort?: string) {
if (!sort) {
return desc(msOptions.updatedAt);
}
try {
const parsed = JSON.parse(sort) as Array<{ id: string; desc?: boolean }>;
const [rule] = parsed;
if (!rule) {
return desc(msOptions.updatedAt);
}
const sortMap = {
category: msOptions.category,
code: msOptions.code,
name: msOptions.label,
label: msOptions.label,
sortOrder: msOptions.sortOrder,
updatedAt: msOptions.updatedAt
} as const;
const column = sortMap[rule.id as keyof typeof sortMap];
if (!column) {
return desc(msOptions.updatedAt);
}
return rule.desc ? desc(column) : asc(column);
} catch {
return desc(msOptions.updatedAt);
}
}
function buildFilters(organizationId: string, filters: MasterOptionListFilters): SQL[] {
return [
eq(msOptions.organizationId, organizationId),
...(filters.category ? [eq(msOptions.category, filters.category)] : []),
...(filters.activeOnly ? [eq(msOptions.isActive, true)] : []),
...(!filters.includeDeleted ? [isNull(msOptions.deletedAt)] : []),
...(filters.search
? [
or(
ilike(msOptions.label, `%${filters.search}%`),
ilike(msOptions.code, `%${filters.search}%`),
ilike(msOptions.category, `%${filters.search}%`)
)!
]
: [])
];
}
function mapMasterOption(
row: typeof msOptions.$inferSelect,
parentLabel: string | null
): MasterOptionRecord {
return {
id: row.id,
organizationId: row.organizationId,
category: row.category,
code: row.code,
label: row.label,
value: row.value,
parentId: row.parentId,
parentLabel,
sortOrder: row.sortOrder,
isActive: row.isActive,
metadata:
row.metadata && typeof row.metadata === 'object'
? (row.metadata as Record<string, unknown>)
: null,
deletedAt: row.deletedAt ? row.deletedAt.toISOString() : null,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString()
};
}
export async function listMasterOptions(
filters: MasterOptionListFilters = {}
): Promise<MasterOptionListResult> {
const organizationId = await resolveOrganizationId(filters.organizationId);
const page = filters.page ?? 1;
const limit = filters.limit ?? 10;
const whereFilters = buildFilters(organizationId, filters);
const where = whereFilters.length === 1 ? whereFilters[0] : and(...whereFilters);
const offset = (page - 1) * limit;
const [totalResult] = await db.select({ value: count() }).from(msOptions).where(where);
const rows = await db
.select()
.from(msOptions)
.where(where)
.orderBy(parseSort(filters.sort))
.limit(limit)
.offset(offset);
const parentIds = [
...new Set(rows.map((row) => row.parentId).filter((value): value is string => Boolean(value)))
];
const parentRows = parentIds.length
? await db
.select()
.from(msOptions)
.where(and(eq(msOptions.organizationId, organizationId), inArray(msOptions.id, parentIds)))
: [];
const parentMap = new Map(parentRows.map((row) => [row.id, row.label]));
return {
items: rows.map((row) => mapMasterOption(row, parentMap.get(row.parentId ?? '') ?? null)),
totalItems: totalResult?.value ?? 0
};
}
export async function getOptionsByCategory(
category: string,
options?: {
organizationId?: string;
includeDeleted?: boolean;
activeOnly?: boolean;
}
): Promise<MasterOptionNode[]> {
const organizationId = await resolveOrganizationId(options?.organizationId);
const whereFilters = buildFilters(organizationId, {
category,
activeOnly: options?.activeOnly,
includeDeleted: options?.includeDeleted,
limit: Number.MAX_SAFE_INTEGER,
page: 1
});
const where = whereFilters.length === 1 ? whereFilters[0] : and(...whereFilters);
const rows = await db
.select()
.from(msOptions)
.where(where)
.orderBy(asc(msOptions.sortOrder), asc(msOptions.label));
const parentMap = new Map(rows.map((row) => [row.id, row]));
const itemMap = new Map<string, MasterOptionNode>();
for (const row of rows) {
const parent = row.parentId ? (parentMap.get(row.parentId) ?? null) : null;
itemMap.set(
row.id,
Object.assign(mapMasterOption(row, parent?.label ?? null), {
children: []
})
);
}
const roots: MasterOptionNode[] = [];
for (const item of itemMap.values()) {
if (item.parentId) {
const parent = itemMap.get(item.parentId);
if (parent) {
parent.children.push(item);
continue;
}
}
roots.push(item);
}
return roots;
}
export async function getActiveOptionsByCategory(
category: string,
options?: { organizationId?: string }
) {
return getOptionsByCategory(category, {
organizationId: options?.organizationId,
activeOnly: true
});
}

View File

@@ -0,0 +1,51 @@
export const CRM_MASTER_OPTION_CATEGORIES = [
'crm_branch',
'crm_customer_status',
'crm_customer_type',
'crm_enquiry_status',
'crm_quotation_status',
'crm_product_type',
'crm_currency',
'crm_payment_term',
'crm_priority',
'crm_lead_channel'
] as const;
export type CrmMasterOptionCategory = (typeof CRM_MASTER_OPTION_CATEGORIES)[number];
export interface MasterOptionRecord {
id: string;
organizationId: string;
category: string;
code: string;
label: string;
value: string | null;
parentId: string | null;
parentLabel: string | null;
sortOrder: number;
isActive: boolean;
metadata: Record<string, unknown> | null;
deletedAt: string | null;
createdAt: string;
updatedAt: string;
}
export interface MasterOptionNode extends MasterOptionRecord {
children: MasterOptionNode[];
}
export interface MasterOptionListFilters {
organizationId?: string;
category?: string;
search?: string;
page?: number;
limit?: number;
sort?: string;
activeOnly?: boolean;
includeDeleted?: boolean;
}
export interface MasterOptionListResult {
items: MasterOptionRecord[];
totalItems: number;
}

View File

@@ -0,0 +1,16 @@
import { requireOrganizationAccess as requireOrganizationAccessFromSession } from '@/lib/auth/session';
import { getCurrentUserContext } from '@/features/foundation/auth-context/service';
export async function getCurrentOrganization() {
const context = await getCurrentUserContext();
return context?.activeOrganization ?? null;
}
export async function getActiveOrganizationId() {
const context = await getCurrentUserContext();
return context?.activeOrganization?.id ?? null;
}
export const requireOrganizationAccess = requireOrganizationAccessFromSession;

View File

@@ -0,0 +1,44 @@
import { AuthError } from '@/lib/auth/session';
import { getCurrentUserContext } from '@/features/foundation/auth-context/service';
import { requireOrganizationAccess } from '@/features/foundation/organization-context/service';
export async function hasPermission(permission: string) {
const context = await getCurrentUserContext();
if (!context) {
return false;
}
if (context.user.systemRole === 'super_admin') {
return true;
}
return context.permissions.includes(permission);
}
export async function requirePermission(permission: string) {
try {
await requireOrganizationAccess({ permission });
return true;
} catch (error) {
if (error instanceof AuthError) {
throw error;
}
throw new AuthError('Forbidden', 403);
}
}
export async function hasBusinessRole(role: string) {
const context = await getCurrentUserContext();
if (!context) {
return false;
}
if (context.user.systemRole === 'super_admin') {
return true;
}
return context.businessRole === role;
}