task-b complate
This commit is contained in:
@@ -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
|
||||
};
|
||||
}
|
||||
25
src/features/foundation/auth-context/types.ts
Normal file
25
src/features/foundation/auth-context/types.ts
Normal 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;
|
||||
}
|
||||
45
src/features/foundation/branch-scope/service.ts
Normal file
45
src/features/foundation/branch-scope/service.ts
Normal 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;
|
||||
}
|
||||
6
src/features/foundation/branch-scope/types.ts
Normal file
6
src/features/foundation/branch-scope/types.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export interface FoundationBranch {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
value: string | null;
|
||||
}
|
||||
166
src/features/foundation/document-sequence/service.ts
Normal file
166
src/features/foundation/document-sequence/service.ts
Normal 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
|
||||
};
|
||||
});
|
||||
}
|
||||
16
src/features/foundation/document-sequence/types.ts
Normal file
16
src/features/foundation/document-sequence/types.ts
Normal 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;
|
||||
}
|
||||
1
src/features/foundation/master-options/api/mutations.ts
Normal file
1
src/features/foundation/master-options/api/mutations.ts
Normal file
@@ -0,0 +1 @@
|
||||
export {};
|
||||
14
src/features/foundation/master-options/api/queries.ts
Normal file
14
src/features/foundation/master-options/api/queries.ts
Normal 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)
|
||||
});
|
||||
18
src/features/foundation/master-options/api/service.ts
Normal file
18
src/features/foundation/master-options/api/service.ts
Normal 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}` : ''}`);
|
||||
}
|
||||
32
src/features/foundation/master-options/api/types.ts
Normal file
32
src/features/foundation/master-options/api/types.ts
Normal 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[];
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
];
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
202
src/features/foundation/master-options/service.ts
Normal file
202
src/features/foundation/master-options/service.ts
Normal 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
|
||||
});
|
||||
}
|
||||
51
src/features/foundation/master-options/types.ts
Normal file
51
src/features/foundation/master-options/types.ts
Normal 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;
|
||||
}
|
||||
16
src/features/foundation/organization-context/service.ts
Normal file
16
src/features/foundation/organization-context/service.ts
Normal 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;
|
||||
44
src/features/foundation/permission/service.ts
Normal file
44
src/features/foundation/permission/service.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user