init
This commit is contained in:
99
src/features/assets/api/mutations.ts
Normal file
99
src/features/assets/api/mutations.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
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
|
||||
});
|
||||
84
src/features/assets/api/queries.ts
Normal file
84
src/features/assets/api/queries.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
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)
|
||||
});
|
||||
}
|
||||
125
src/features/assets/api/service.ts
Normal file
125
src/features/assets/api/service.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
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'
|
||||
});
|
||||
}
|
||||
254
src/features/assets/api/types.ts
Normal file
254
src/features/assets/api/types.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
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[];
|
||||
}
|
||||
327
src/features/assets/components/asset-action-form.tsx
Normal file
327
src/features/assets/components/asset-action-form.tsx
Normal file
@@ -0,0 +1,327 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
111
src/features/assets/components/asset-detail-view.tsx
Normal file
111
src/features/assets/components/asset-detail-view.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
221
src/features/assets/components/asset-form.tsx
Normal file
221
src/features/assets/components/asset-form.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
36
src/features/assets/components/asset-history-view.tsx
Normal file
36
src/features/assets/components/asset-history-view.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
32
src/features/assets/components/asset-listing.tsx
Normal file
32
src/features/assets/components/asset-listing.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
33
src/features/assets/components/asset-summary-cards.tsx
Normal file
33
src/features/assets/components/asset-summary-cards.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
74
src/features/assets/components/assets-table/cell-action.tsx
Normal file
74
src/features/assets/components/assets-table/cell-action.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
'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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
128
src/features/assets/components/assets-table/columns.tsx
Normal file
128
src/features/assets/components/assets-table/columns.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
'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} />
|
||||
}
|
||||
];
|
||||
56
src/features/assets/components/assets-table/index.tsx
Normal file
56
src/features/assets/components/assets-table/index.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
'use client';
|
||||
|
||||
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 { columns } from './columns';
|
||||
|
||||
const columnIds = columns.map((c) => c.id).filter(Boolean) as string[];
|
||||
|
||||
export function AssetsTable() {
|
||||
const [params] = useQueryStates({
|
||||
page: parseAsInteger.withDefault(1),
|
||||
perPage: parseAsInteger.withDefault(10),
|
||||
name: parseAsString,
|
||||
status: parseAsString,
|
||||
assetCondition: parseAsString,
|
||||
dispositionStatus: parseAsString,
|
||||
assetType: parseAsString,
|
||||
sort: getSortingStateParser(columnIds).withDefault([])
|
||||
});
|
||||
|
||||
const filters = {
|
||||
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.sort.length > 0 && { sort: JSON.stringify(params.sort) })
|
||||
};
|
||||
|
||||
const { data } = useSuspenseQuery(assetsQueryOptions(filters));
|
||||
const pageCount = Math.ceil(data.total_assets / params.perPage);
|
||||
|
||||
const { table } = useDataTable({
|
||||
data: data.assets,
|
||||
columns,
|
||||
pageCount,
|
||||
shallow: true,
|
||||
debounceMs: 500,
|
||||
initialState: {
|
||||
columnPinning: { right: ['actions'] }
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<DataTable table={table}>
|
||||
<DataTableToolbar table={table} />
|
||||
</DataTable>
|
||||
);
|
||||
}
|
||||
443
src/features/assets/components/master-data-manager.tsx
Normal file
443
src/features/assets/components/master-data-manager.tsx
Normal file
@@ -0,0 +1,443 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
78
src/features/assets/constants.ts
Normal file
78
src/features/assets/constants.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
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;
|
||||
77
src/features/assets/schemas/asset.ts
Normal file
77
src/features/assets/schemas/asset.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
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>;
|
||||
27
src/features/assets/schemas/master-data.ts
Normal file
27
src/features/assets/schemas/master-data.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
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')
|
||||
});
|
||||
13
src/features/auth/components/github-auth-button.tsx
Normal file
13
src/features/auth/components/github-auth-button.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Icons } from '@/components/icons';
|
||||
|
||||
export default function GithubSignInButton() {
|
||||
return (
|
||||
<Button className='w-full' variant='outline' type='button' disabled>
|
||||
<Icons.github className='mr-2 h-4 w-4' />
|
||||
GitHub coming later
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
69
src/features/auth/components/interactive-grid.tsx
Normal file
69
src/features/auth/components/interactive-grid.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
'use client';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
/**
|
||||
* InteractiveGridPattern is a component that renders a grid pattern with interactive squares.
|
||||
*
|
||||
* @param width - The width of each square.
|
||||
* @param height - The height of each square.
|
||||
* @param squares - The number of squares in the grid. The first element is the number of horizontal squares, and the second element is the number of vertical squares.
|
||||
* @param className - The class name of the grid.
|
||||
* @param squaresClassName - The class name of the squares.
|
||||
*/
|
||||
interface InteractiveGridPatternProps extends React.SVGProps<SVGSVGElement> {
|
||||
width?: number;
|
||||
height?: number;
|
||||
squares?: [number, number]; // [horizontal, vertical]
|
||||
className?: string;
|
||||
squaresClassName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The InteractiveGridPattern component.
|
||||
*
|
||||
* @see InteractiveGridPatternProps for the props interface.
|
||||
* @returns A React component.
|
||||
*/
|
||||
export function InteractiveGridPattern({
|
||||
width = 40,
|
||||
height = 40,
|
||||
squares = [24, 24],
|
||||
className,
|
||||
squaresClassName,
|
||||
...props
|
||||
}: InteractiveGridPatternProps) {
|
||||
const [horizontal, vertical] = squares;
|
||||
const [hoveredSquare, setHoveredSquare] = useState<number | null>(null);
|
||||
|
||||
return (
|
||||
<svg
|
||||
width={width * horizontal}
|
||||
height={height * vertical}
|
||||
className={cn('absolute inset-0 h-full w-full border border-gray-400/30', className)}
|
||||
{...props}
|
||||
>
|
||||
{Array.from({ length: horizontal * vertical }).map((_, index) => {
|
||||
const x = (index % horizontal) * width;
|
||||
const y = Math.floor(index / horizontal) * height;
|
||||
return (
|
||||
<rect
|
||||
key={index}
|
||||
x={x}
|
||||
y={y}
|
||||
width={width}
|
||||
height={height}
|
||||
className={cn(
|
||||
'stroke-gray-400/30 transition-all duration-100 ease-in-out [&:not(:hover)]:duration-1000',
|
||||
hoveredSquare === index ? 'fill-gray-300/30' : 'fill-transparent',
|
||||
squaresClassName
|
||||
)}
|
||||
onMouseEnter={() => setHoveredSquare(index)}
|
||||
onMouseLeave={() => setHoveredSquare(null)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
171
src/features/auth/components/sign-in-view.tsx
Normal file
171
src/features/auth/components/sign-in-view.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useAppForm } from "@/components/ui/tanstack-form";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getSession, signIn } from "next-auth/react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useTransition } from "react";
|
||||
import { toast } from "sonner";
|
||||
import * as z from "zod";
|
||||
import { InteractiveGridPattern } from "./interactive-grid";
|
||||
|
||||
const signInSchema = z.object({
|
||||
email: z.string().email("Enter a valid email address"),
|
||||
password: z.string().min(8, "Password must be at least 8 characters"),
|
||||
});
|
||||
|
||||
export default function SignInViewPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const callbackUrl = searchParams.get("callbackUrl") ?? "/dashboard";
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const form = useAppForm({
|
||||
defaultValues: {
|
||||
email: "",
|
||||
password: "",
|
||||
},
|
||||
validators: {
|
||||
onSubmit: signInSchema,
|
||||
},
|
||||
onSubmit: ({ value }) => {
|
||||
startTransition(async () => {
|
||||
const result = await signIn("credentials", {
|
||||
email: value.email,
|
||||
password: value.password,
|
||||
redirect: false,
|
||||
});
|
||||
|
||||
if (result?.error) {
|
||||
toast.error("Invalid email or password");
|
||||
return;
|
||||
}
|
||||
|
||||
await getSession();
|
||||
toast.success("Signed in successfully");
|
||||
window.location.assign(callbackUrl);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-screen flex-col items-center justify-center overflow-hidden md:grid lg:max-w-none lg:grid-cols-2 lg:px-0">
|
||||
<div className="relative hidden h-full flex-col p-10 lg:flex dark:border-r">
|
||||
<div className="absolute inset-0 bg-sidebar" />
|
||||
<div className="text-sidebar-foreground relative z-20 flex items-center text-lg font-medium">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="mr-2 h-6 w-6"
|
||||
>
|
||||
<path d="M15 6v12a3 3 0 1 0 3-3H6a3 3 0 1 0 3 3V6a3 3 0 1 0-3 3h12a3 3 0 1 0-3-3" />
|
||||
</svg>
|
||||
Auth.js Workspace Login
|
||||
</div>
|
||||
<InteractiveGridPattern
|
||||
className={cn(
|
||||
"mask-[radial-gradient(400px_circle_at_center,white,transparent)]",
|
||||
"inset-x-0 inset-y-[0%] h-full skew-y-12",
|
||||
)}
|
||||
/>
|
||||
<div className="text-sidebar-foreground relative z-20 mt-auto">
|
||||
<blockquote className="space-y-2">
|
||||
<p className="text-lg">
|
||||
“Move from template scaffolding to app-owned auth and data
|
||||
without tearing up the dashboard UX.”
|
||||
</p>
|
||||
<footer className="text-sidebar-foreground/70 text-sm">
|
||||
Training System
|
||||
</footer>
|
||||
</blockquote>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex h-full items-center justify-center p-4 lg:p-8">
|
||||
<div className="flex w-full max-w-md flex-col items-center justify-center space-y-6">
|
||||
<div className="w-full space-y-2 text-center">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
Sign in to your workspace
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Use the credentials created for you by a super admin or
|
||||
organization admin.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form.AppForm>
|
||||
<form.Form className="w-full space-y-4">
|
||||
<form.AppField
|
||||
name="email"
|
||||
children={(field) => (
|
||||
<field.FieldSet>
|
||||
<field.Field>
|
||||
<field.FieldLabel htmlFor={field.name}>
|
||||
Email
|
||||
</field.FieldLabel>
|
||||
<Input
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
type="email"
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(event) =>
|
||||
field.handleChange(event.target.value)
|
||||
}
|
||||
placeholder="you@example.com"
|
||||
disabled={isPending}
|
||||
/>
|
||||
</field.Field>
|
||||
<field.FieldError />
|
||||
</field.FieldSet>
|
||||
)}
|
||||
/>
|
||||
|
||||
<form.AppField
|
||||
name="password"
|
||||
children={(field) => (
|
||||
<field.FieldSet>
|
||||
<field.Field>
|
||||
<field.FieldLabel htmlFor={field.name}>
|
||||
Password
|
||||
</field.FieldLabel>
|
||||
<Input
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
type="password"
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(event) =>
|
||||
field.handleChange(event.target.value)
|
||||
}
|
||||
placeholder="Enter your password"
|
||||
disabled={isPending}
|
||||
/>
|
||||
</field.Field>
|
||||
<field.FieldError />
|
||||
</field.FieldSet>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button className="w-full" type="submit" isLoading={isPending}>
|
||||
Sign in
|
||||
</Button>
|
||||
</form.Form>
|
||||
</form.AppForm>
|
||||
|
||||
<div className="text-muted-foreground space-y-2 px-8 text-center text-xs">
|
||||
<p>
|
||||
This starter now uses app-owned authentication with Auth.js and an
|
||||
internal organization model.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
219
src/features/auth/components/sign-up-view.tsx
Normal file
219
src/features/auth/components/sign-up-view.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
'use client';
|
||||
|
||||
import { Button, buttonVariants } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useAppForm } from '@/components/ui/tanstack-form';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { signIn } from 'next-auth/react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useTransition } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import * as z from 'zod';
|
||||
import { InteractiveGridPattern } from './interactive-grid';
|
||||
|
||||
const signUpSchema = z.object({
|
||||
name: z.string().min(2, 'Name must be at least 2 characters'),
|
||||
email: z.string().email('Enter a valid email address'),
|
||||
password: z.string().min(8, 'Password must be at least 8 characters'),
|
||||
workspaceName: z.string().min(2, 'Workspace name must be at least 2 characters')
|
||||
});
|
||||
|
||||
export default function SignUpViewPage() {
|
||||
const router = useRouter();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const form = useAppForm({
|
||||
defaultValues: {
|
||||
name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
workspaceName: ''
|
||||
},
|
||||
validators: {
|
||||
onSubmit: signUpSchema
|
||||
},
|
||||
onSubmit: ({ value }) => {
|
||||
startTransition(async () => {
|
||||
const response = await fetch('/api/auth/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(value)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = (await response.json().catch(() => null)) as { message?: string } | null;
|
||||
toast.error(data?.message ?? 'Unable to create account');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await signIn('credentials', {
|
||||
email: value.email,
|
||||
password: value.password,
|
||||
redirect: false
|
||||
});
|
||||
|
||||
if (result?.error) {
|
||||
toast.error('Account created, but sign-in failed. Please sign in manually.');
|
||||
router.push('/auth/sign-in');
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success('Account created successfully');
|
||||
router.push('/dashboard');
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className='relative min-h-screen items-center justify-center md:grid lg:max-w-none lg:grid-cols-2 lg:px-0'>
|
||||
<Link
|
||||
href='/auth/sign-in'
|
||||
className={cn(
|
||||
buttonVariants({ variant: 'ghost' }),
|
||||
'absolute top-4 right-4 hidden md:top-8 md:right-8'
|
||||
)}
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
<div className='relative hidden h-full flex-col p-10 lg:flex dark:border-r'>
|
||||
<div className='absolute inset-0 bg-sidebar' />
|
||||
<div className='text-sidebar-foreground relative z-20 flex items-center text-lg font-medium'>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
className='mr-2 h-6 w-6'
|
||||
>
|
||||
<path d='M15 6v12a3 3 0 1 0 3-3H6a3 3 0 1 0 3 3V6a3 3 0 1 0-3 3h12a3 3 0 1 0-3-3' />
|
||||
</svg>
|
||||
Create your first workspace
|
||||
</div>
|
||||
<InteractiveGridPattern
|
||||
className={cn(
|
||||
'mask-[radial-gradient(400px_circle_at_center,white,transparent)]',
|
||||
'inset-x-0 inset-y-[0%] h-full skew-y-12'
|
||||
)}
|
||||
/>
|
||||
<div className='text-sidebar-foreground relative z-20 mt-auto'>
|
||||
<blockquote className='space-y-2'>
|
||||
<p className='text-lg'>
|
||||
“Own the auth boundary, own the org model, and keep your feature modules
|
||||
stable.”
|
||||
</p>
|
||||
<footer className='text-sidebar-foreground/70 text-sm'>Training System</footer>
|
||||
</blockquote>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex h-full items-center justify-center p-4 lg:p-8'>
|
||||
<div className='flex w-full max-w-md flex-col items-center justify-center space-y-6'>
|
||||
<div className='w-full space-y-2 text-center'>
|
||||
<h1 className='text-2xl font-semibold tracking-tight'>Create an account</h1>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
We'll create your user, your first workspace, and sign you in.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form.AppForm>
|
||||
<form.Form className='w-full space-y-4'>
|
||||
<form.AppField
|
||||
name='name'
|
||||
children={(field) => (
|
||||
<field.FieldSet>
|
||||
<field.Field>
|
||||
<field.FieldLabel htmlFor={field.name}>Full name</field.FieldLabel>
|
||||
<Input
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(event) => field.handleChange(event.target.value)}
|
||||
placeholder='Jane Doe'
|
||||
disabled={isPending}
|
||||
/>
|
||||
</field.Field>
|
||||
<field.FieldError />
|
||||
</field.FieldSet>
|
||||
)}
|
||||
/>
|
||||
|
||||
<form.AppField
|
||||
name='workspaceName'
|
||||
children={(field) => (
|
||||
<field.FieldSet>
|
||||
<field.Field>
|
||||
<field.FieldLabel htmlFor={field.name}>Workspace name</field.FieldLabel>
|
||||
<Input
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(event) => field.handleChange(event.target.value)}
|
||||
placeholder='Acme Studio'
|
||||
disabled={isPending}
|
||||
/>
|
||||
</field.Field>
|
||||
<field.FieldError />
|
||||
</field.FieldSet>
|
||||
)}
|
||||
/>
|
||||
|
||||
<form.AppField
|
||||
name='email'
|
||||
children={(field) => (
|
||||
<field.FieldSet>
|
||||
<field.Field>
|
||||
<field.FieldLabel htmlFor={field.name}>Email</field.FieldLabel>
|
||||
<Input
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
type='email'
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(event) => field.handleChange(event.target.value)}
|
||||
placeholder='you@example.com'
|
||||
disabled={isPending}
|
||||
/>
|
||||
</field.Field>
|
||||
<field.FieldError />
|
||||
</field.FieldSet>
|
||||
)}
|
||||
/>
|
||||
|
||||
<form.AppField
|
||||
name='password'
|
||||
children={(field) => (
|
||||
<field.FieldSet>
|
||||
<field.Field>
|
||||
<field.FieldLabel htmlFor={field.name}>Password</field.FieldLabel>
|
||||
<Input
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
type='password'
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(event) => field.handleChange(event.target.value)}
|
||||
placeholder='Minimum 8 characters'
|
||||
disabled={isPending}
|
||||
/>
|
||||
</field.Field>
|
||||
<field.FieldError />
|
||||
</field.FieldSet>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button className='w-full' type='submit' isLoading={isPending}>
|
||||
Create account
|
||||
</Button>
|
||||
</form.Form>
|
||||
</form.AppForm>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
116
src/features/auth/components/user-auth-form.tsx
Normal file
116
src/features/auth/components/user-auth-form.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useAppForm } from "@/components/ui/tanstack-form";
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTransition } from "react";
|
||||
import { toast } from "sonner";
|
||||
import * as z from "zod";
|
||||
|
||||
const formSchema = z.object({
|
||||
email: z.string().email({ message: "Enter a valid email address" }),
|
||||
password: z
|
||||
.string()
|
||||
.min(8, { message: "Password must be at least 8 characters" }),
|
||||
});
|
||||
|
||||
export default function UserAuthForm() {
|
||||
const router = useRouter();
|
||||
const [loading, startTransition] = useTransition();
|
||||
|
||||
const form = useAppForm({
|
||||
defaultValues: {
|
||||
email: "",
|
||||
password: "",
|
||||
},
|
||||
validators: {
|
||||
onSubmit: formSchema,
|
||||
},
|
||||
onSubmit: ({ value }) => {
|
||||
startTransition(async () => {
|
||||
const result = await signIn("credentials", {
|
||||
email: value.email,
|
||||
password: value.password,
|
||||
redirect: false,
|
||||
});
|
||||
|
||||
if (result?.error) {
|
||||
toast.error("Invalid email or password");
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success("Signed in successfully");
|
||||
router.push("/dashboard");
|
||||
router.refresh();
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<form.AppForm>
|
||||
<form.Form className="w-full space-y-2">
|
||||
<form.AppField
|
||||
name="email"
|
||||
children={(field) => (
|
||||
<field.FieldSet>
|
||||
<field.Field>
|
||||
<field.FieldLabel htmlFor={field.name}>
|
||||
Email
|
||||
</field.FieldLabel>
|
||||
<Input
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
placeholder="Enter your email..."
|
||||
disabled={loading}
|
||||
aria-invalid={
|
||||
field.state.meta.isTouched && !field.state.meta.isValid
|
||||
}
|
||||
/>
|
||||
</field.Field>
|
||||
<field.FieldError />
|
||||
</field.FieldSet>
|
||||
)}
|
||||
/>
|
||||
<form.AppField
|
||||
name="password"
|
||||
children={(field) => (
|
||||
<field.FieldSet>
|
||||
<field.Field>
|
||||
<field.FieldLabel htmlFor={field.name}>
|
||||
Password
|
||||
</field.FieldLabel>
|
||||
<Input
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
type="password"
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
placeholder="Enter your password..."
|
||||
disabled={loading}
|
||||
aria-invalid={
|
||||
field.state.meta.isTouched && !field.state.meta.isValid
|
||||
}
|
||||
/>
|
||||
</field.Field>
|
||||
<field.FieldError />
|
||||
</field.FieldSet>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
disabled={loading}
|
||||
className="mt-2 ml-auto w-full"
|
||||
type="submit"
|
||||
>
|
||||
Continue with Email
|
||||
</Button>
|
||||
</form.Form>
|
||||
</form.AppForm>
|
||||
</>
|
||||
);
|
||||
}
|
||||
98
src/features/chat/components/chat-area.tsx
Normal file
98
src/features/chat/components/chat-area.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
'use client';
|
||||
|
||||
import { FormEvent, useEffect, useRef } from 'react';
|
||||
import { AnimatePresence, motion, useReducedMotion } from 'motion/react';
|
||||
import type { Attachment, Conversation } from '../utils/types';
|
||||
import { ChatHeader } from './chat-header';
|
||||
import { MessageBubble } from './message-bubble';
|
||||
import { MessageComposer } from './message-composer';
|
||||
|
||||
interface ChatAreaProps {
|
||||
conversation: Conversation;
|
||||
draft: string;
|
||||
onDraftChange: (text: string) => void;
|
||||
onSubmit: (e: FormEvent<HTMLFormElement>) => void;
|
||||
attachments: Attachment[];
|
||||
onAddAttachments: (files: FileList) => void;
|
||||
onRemoveAttachment: (id: string) => void;
|
||||
}
|
||||
|
||||
export function ChatArea({
|
||||
conversation,
|
||||
draft,
|
||||
onDraftChange,
|
||||
onSubmit,
|
||||
attachments,
|
||||
onAddAttachments,
|
||||
onRemoveAttachment
|
||||
}: ChatAreaProps) {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const messagesContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const liveRegionRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!messagesContainerRef.current) return;
|
||||
const container = messagesContainerRef.current;
|
||||
const behavior = shouldReduceMotion ? 'auto' : 'smooth';
|
||||
|
||||
const scrollToBottom = () => {
|
||||
container.scrollTo({ top: container.scrollHeight, behavior });
|
||||
};
|
||||
|
||||
if (behavior === 'smooth') {
|
||||
requestAnimationFrame(scrollToBottom);
|
||||
} else {
|
||||
scrollToBottom();
|
||||
}
|
||||
}, [conversation.messages, conversation.id, shouldReduceMotion]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!liveRegionRef.current) return;
|
||||
const lastMessage = conversation.messages[conversation.messages.length - 1];
|
||||
if (!lastMessage) return;
|
||||
liveRegionRef.current.textContent =
|
||||
lastMessage.author + ' at ' + lastMessage.timestamp + ': ' + lastMessage.text;
|
||||
}, [conversation.messages]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AnimatePresence initial={false} mode='wait'>
|
||||
<motion.div
|
||||
key={conversation.id}
|
||||
initial={shouldReduceMotion ? { opacity: 1 } : { opacity: 0, y: 12 }}
|
||||
animate={shouldReduceMotion ? { opacity: 1 } : { opacity: 1, y: 0 }}
|
||||
exit={shouldReduceMotion ? { opacity: 0 } : { opacity: 0, y: -12 }}
|
||||
transition={{ duration: 0.32, ease: 'easeOut' }}
|
||||
className='border-border/40 bg-background/80 flex min-h-0 flex-col gap-3 overflow-hidden rounded-2xl border p-3 backdrop-blur sm:gap-4 sm:p-4 lg:col-start-2 lg:col-end-3 lg:rounded-3xl'
|
||||
>
|
||||
<ChatHeader conversation={conversation} />
|
||||
|
||||
<div
|
||||
ref={messagesContainerRef}
|
||||
className='[&::-webkit-scrollbar-thumb]:bg-muted relative min-h-0 flex-1 space-y-3 overflow-y-auto pr-2 sm:space-y-4 [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-thumb]:rounded-full'
|
||||
aria-live='off'
|
||||
aria-label={'Message thread with ' + conversation.name}
|
||||
>
|
||||
<AnimatePresence initial={false}>
|
||||
{conversation.messages.map((message) => (
|
||||
<MessageBubble key={message.id} message={message} />
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<MessageComposer
|
||||
draft={draft}
|
||||
onDraftChange={onDraftChange}
|
||||
onSubmit={onSubmit}
|
||||
contactName={conversation.name}
|
||||
quickReplies={conversation.quickReplies}
|
||||
attachments={attachments}
|
||||
onAddAttachments={onAddAttachments}
|
||||
onRemoveAttachment={onRemoveAttachment}
|
||||
/>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
<div ref={liveRegionRef} className='sr-only' aria-live='polite' aria-atomic='true' />
|
||||
</>
|
||||
);
|
||||
}
|
||||
73
src/features/chat/components/chat-header.tsx
Normal file
73
src/features/chat/components/chat-header.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
'use client';
|
||||
|
||||
import { Icons } from '@/components/icons';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { Conversation } from '../utils/types';
|
||||
|
||||
const statusDotColor = {
|
||||
online: 'bg-green-500',
|
||||
offline: 'bg-red-500'
|
||||
} as const;
|
||||
|
||||
interface ChatHeaderProps {
|
||||
conversation: Conversation;
|
||||
}
|
||||
|
||||
export function ChatHeader({ conversation }: ChatHeaderProps) {
|
||||
return (
|
||||
<header className='flex flex-wrap items-center justify-between gap-3 sm:gap-4'>
|
||||
<div className='flex items-center gap-2 sm:gap-3'>
|
||||
<div className='relative'>
|
||||
<Avatar className='border-border/40 bg-card/80 text-foreground h-10 w-10 rounded-2xl border sm:h-12 sm:w-12 sm:rounded-3xl'>
|
||||
<AvatarFallback className='bg-primary/20 text-primary rounded-2xl text-sm font-semibold sm:rounded-3xl sm:text-base'>
|
||||
{conversation.initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span
|
||||
className={cn(
|
||||
'border-background absolute right-0 bottom-0 inline-flex h-3 w-3 rounded-full border-2 sm:h-3.5 sm:w-3.5',
|
||||
statusDotColor[conversation.status]
|
||||
)}
|
||||
aria-label={conversation.status === 'online' ? 'Online' : 'Offline'}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-foreground text-sm font-semibold sm:text-base'>{conversation.name}</p>
|
||||
<p className='text-muted-foreground text-xs sm:text-sm'>{conversation.title}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center gap-1.5 sm:gap-2'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='border-border/40 bg-background/60 text-muted-foreground hover:bg-muted/60 focus-visible:ring-primary/40 focus-visible:ring-offset-background size-8 rounded-full border transition focus-visible:ring-2 focus-visible:ring-offset-2 sm:size-10'
|
||||
aria-label='Start audio call'
|
||||
>
|
||||
<Icons.phone className='h-3.5 w-3.5 sm:h-4 sm:w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='border-border/40 bg-background/60 text-muted-foreground hover:bg-muted/60 focus-visible:ring-primary/40 focus-visible:ring-offset-background size-8 rounded-full border transition focus-visible:ring-2 focus-visible:ring-offset-2 sm:size-10'
|
||||
aria-label='Start video call'
|
||||
>
|
||||
<Icons.video className='h-3.5 w-3.5 sm:h-4 sm:w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='border-border/40 bg-background/60 text-muted-foreground hover:bg-muted/60 focus-visible:ring-primary/40 focus-visible:ring-offset-background size-8 rounded-full border transition focus-visible:ring-2 focus-visible:ring-offset-2 sm:size-10'
|
||||
aria-label='Open conversation menu'
|
||||
>
|
||||
<Icons.ellipsis className='h-3.5 w-3.5 sm:h-4 sm:w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
11
src/features/chat/components/chat-view-page.tsx
Normal file
11
src/features/chat/components/chat-view-page.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { Messenger } from './messenger';
|
||||
|
||||
export default function ChatViewPage() {
|
||||
return (
|
||||
<div className='flex min-h-0 flex-1 px-4 py-2 md:px-6'>
|
||||
<Messenger />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
140
src/features/chat/components/conversation-list.tsx
Normal file
140
src/features/chat/components/conversation-list.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Icons } from '@/components/icons';
|
||||
import { motion } from 'motion/react';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { Conversation } from '../utils/types';
|
||||
|
||||
const statusDotColor = {
|
||||
online: 'bg-green-500',
|
||||
offline: 'bg-red-500'
|
||||
} as const;
|
||||
|
||||
interface ConversationListProps {
|
||||
conversations: Conversation[];
|
||||
selectedId: string;
|
||||
onSelect: (id: string) => void;
|
||||
}
|
||||
|
||||
export function ConversationList({ conversations, selectedId, onSelect }: ConversationListProps) {
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!search.trim()) return conversations;
|
||||
const q = search.toLowerCase();
|
||||
return conversations.filter(
|
||||
(c) => c.name.toLowerCase().includes(q) || c.title.toLowerCase().includes(q)
|
||||
);
|
||||
}, [conversations, search]);
|
||||
|
||||
return (
|
||||
<div className='border-border/40 bg-background/75 hidden h-full flex-col gap-4 overflow-hidden rounded-2xl border p-3 backdrop-blur lg:col-start-1 lg:col-end-2 lg:flex lg:rounded-3xl lg:p-4'>
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<div>
|
||||
<p className='text-foreground text-sm font-semibold'>Messenger</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{conversations.length} active conversation
|
||||
{conversations.length === 1 ? '' : 's'}
|
||||
</p>
|
||||
</div>
|
||||
<Badge
|
||||
variant='outline'
|
||||
className='bg-primary/15 text-primary hover:bg-primary/15 hover:text-primary border-border/50 rounded-full border px-3 py-1 text-[0.7rem] tracking-[0.24em] uppercase'
|
||||
>
|
||||
Live
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<label htmlFor='messenger-search' className='sr-only'>
|
||||
Search conversations
|
||||
</label>
|
||||
<div className='relative'>
|
||||
<Icons.search
|
||||
className='text-muted-foreground/70 pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2'
|
||||
aria-hidden='true'
|
||||
/>
|
||||
<Input
|
||||
id='messenger-search'
|
||||
type='search'
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder='Search conversations'
|
||||
className='border-border/40 bg-background/60 text-foreground placeholder:text-muted-foreground/70 focus-visible:ring-primary/40 w-full rounded-2xl pl-10 text-sm focus-visible:ring-2'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className='flex-1 space-y-2 overflow-y-auto pr-1'
|
||||
aria-label='Conversation list'
|
||||
role='list'
|
||||
>
|
||||
{filtered.length === 0 ? (
|
||||
<p className='text-muted-foreground py-8 text-center text-xs'>No conversations found</p>
|
||||
) : null}
|
||||
{filtered.map((conversation) => {
|
||||
const isActive = conversation.id === selectedId;
|
||||
const lastMessage = conversation.messages[conversation.messages.length - 1];
|
||||
return (
|
||||
<motion.button
|
||||
key={conversation.id}
|
||||
type='button'
|
||||
onClick={() => onSelect(conversation.id)}
|
||||
aria-current={isActive ? 'true' : undefined}
|
||||
className={cn(
|
||||
'focus-visible:ring-primary/50 group focus-visible:ring-offset-background relative flex w-full items-start gap-3 rounded-2xl border border-transparent p-3 text-left transition-all focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none',
|
||||
isActive
|
||||
? 'border-primary/40 bg-primary/10'
|
||||
: 'bg-background/70 hover:border-border/40 hover:bg-muted/40'
|
||||
)}
|
||||
role='listitem'
|
||||
>
|
||||
<div className='relative shrink-0'>
|
||||
<Avatar className='border-border/40 bg-background/80 text-foreground h-10 w-10 rounded-2xl border'>
|
||||
<AvatarFallback className='bg-primary/15 text-primary rounded-2xl text-sm font-medium'>
|
||||
{conversation.initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span
|
||||
className={cn(
|
||||
'border-background absolute right-0 bottom-0 inline-flex h-3 w-3 rounded-full border-2',
|
||||
statusDotColor[conversation.status]
|
||||
)}
|
||||
aria-label={conversation.status === 'online' ? 'Online' : 'Offline'}
|
||||
/>
|
||||
</div>
|
||||
<div className='min-w-0 flex-1 space-y-1'>
|
||||
<div className='flex items-start justify-between gap-2'>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<p className='text-foreground text-sm font-semibold'>{conversation.name}</p>
|
||||
<p className='text-muted-foreground text-xs'>{conversation.title}</p>
|
||||
</div>
|
||||
{lastMessage && (
|
||||
<span className='text-muted-foreground shrink-0 text-[0.65rem]'>
|
||||
{lastMessage.timestamp}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{lastMessage ? (
|
||||
<p className='text-muted-foreground line-clamp-2 text-xs'>
|
||||
{lastMessage.author}: {lastMessage.text}
|
||||
</p>
|
||||
) : (
|
||||
<p className='text-muted-foreground text-xs'>No messages yet</p>
|
||||
)}
|
||||
</div>
|
||||
{conversation.unread > 0 && (
|
||||
<span className='bg-primary text-primary-foreground ml-2 inline-flex min-h-[1.5rem] min-w-[1.5rem] items-center justify-center rounded-full text-[0.7rem] font-semibold shadow-lg'>
|
||||
{conversation.unread}
|
||||
</span>
|
||||
)}
|
||||
</motion.button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
56
src/features/chat/components/conversation-select.tsx
Normal file
56
src/features/chat/components/conversation-select.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
'use client';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import type { Conversation } from '../utils/types';
|
||||
|
||||
interface ConversationSelectProps {
|
||||
conversations: Conversation[];
|
||||
selectedId: string;
|
||||
onSelect: (id: string) => void;
|
||||
}
|
||||
|
||||
export function ConversationSelect({
|
||||
conversations,
|
||||
selectedId,
|
||||
onSelect
|
||||
}: ConversationSelectProps) {
|
||||
return (
|
||||
<div className='border-border/40 bg-background/75 flex flex-col gap-3 rounded-2xl border p-3 backdrop-blur sm:gap-4 sm:rounded-3xl sm:p-4 lg:hidden'>
|
||||
<div className='flex items-center justify-between gap-2 sm:gap-3'>
|
||||
<div>
|
||||
<p className='text-foreground text-xs font-semibold sm:text-sm'>Messenger</p>
|
||||
<p className='text-muted-foreground text-[0.65rem] sm:text-xs'>
|
||||
{conversations.length} active conversation
|
||||
{conversations.length === 1 ? '' : 's'}
|
||||
</p>
|
||||
</div>
|
||||
<Badge
|
||||
variant='outline'
|
||||
className='bg-primary/15 text-primary hover:bg-primary/15 hover:text-primary border-border/50 rounded-full border px-2 py-0.5 text-[0.65rem] tracking-[0.2em] uppercase sm:px-3 sm:py-1 sm:text-[0.7rem] sm:tracking-[0.24em]'
|
||||
>
|
||||
Live
|
||||
</Badge>
|
||||
</div>
|
||||
<div className='space-y-1.5 sm:space-y-2'>
|
||||
<label
|
||||
htmlFor='messenger-conversation'
|
||||
className='text-muted-foreground text-[0.65rem] font-medium sm:text-xs'
|
||||
>
|
||||
Conversation
|
||||
</label>
|
||||
<select
|
||||
id='messenger-conversation'
|
||||
value={selectedId}
|
||||
onChange={(e) => onSelect(e.target.value)}
|
||||
className='border-border/40 bg-background/70 text-foreground focus:border-primary/40 focus:ring-primary/30 w-full rounded-xl border px-2.5 py-1.5 text-xs focus:ring-2 focus:outline-none sm:rounded-2xl sm:px-3 sm:py-2 sm:text-sm'
|
||||
>
|
||||
{conversations.map((conversation) => (
|
||||
<option key={conversation.id} value={conversation.id}>
|
||||
{conversation.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
78
src/features/chat/components/message-bubble.tsx
Normal file
78
src/features/chat/components/message-bubble.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
'use client';
|
||||
|
||||
import { Icons } from '@/components/icons';
|
||||
import { motion, useReducedMotion } from 'motion/react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { FilePreview } from '@/components/ui/file-preview';
|
||||
import type { Message } from '../utils/types';
|
||||
|
||||
interface MessageBubbleProps {
|
||||
message: Message;
|
||||
}
|
||||
|
||||
export function MessageBubble({ message }: MessageBubbleProps) {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const isUser = message.sender === 'user';
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={shouldReduceMotion ? false : { opacity: 0, y: 12, scale: 0.98 }}
|
||||
animate={shouldReduceMotion ? { opacity: 1 } : { opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 0 }}
|
||||
transition={{ duration: 0.28, ease: 'easeOut' }}
|
||||
className='flex flex-col gap-1'
|
||||
role='group'
|
||||
aria-label={message.author + ' at ' + message.timestamp}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'relative max-w-[85%] rounded-xl border px-3 py-2 text-xs leading-relaxed sm:max-w-[82%] sm:rounded-2xl sm:px-4 sm:py-3 sm:text-sm',
|
||||
isUser
|
||||
? 'border-primary/40 bg-primary text-primary-foreground ml-auto'
|
||||
: 'bg-muted border-transparent'
|
||||
)}
|
||||
>
|
||||
<p
|
||||
className={cn(
|
||||
'font-medium sm:text-sm',
|
||||
isUser ? 'text-primary-foreground/80' : 'text-foreground/80'
|
||||
)}
|
||||
>
|
||||
{message.author}
|
||||
</p>
|
||||
{message.text && (
|
||||
<p
|
||||
className={cn(
|
||||
'mt-1 text-[0.875rem] sm:text-[0.95rem]',
|
||||
isUser ? 'text-primary-foreground/90' : 'text-foreground/90'
|
||||
)}
|
||||
>
|
||||
{message.text}
|
||||
</p>
|
||||
)}
|
||||
{message.attachments && message.attachments.length > 0 && (
|
||||
<FilePreview
|
||||
files={message.attachments.map((a) => ({
|
||||
id: a.id,
|
||||
name: a.name,
|
||||
type: a.type
|
||||
}))}
|
||||
variant={isUser ? 'inverted' : 'default'}
|
||||
className='mt-1 p-0'
|
||||
/>
|
||||
)}
|
||||
<div className='mt-2 flex items-center justify-end gap-1.5 text-[0.65rem] sm:mt-3 sm:gap-2 sm:text-[0.7rem]'>
|
||||
<span className={cn('text-muted-foreground', isUser && 'text-primary-foreground/80')}>
|
||||
{message.timestamp}
|
||||
</span>
|
||||
{isUser && (
|
||||
<Icons.checks
|
||||
className='text-primary-foreground/80 h-3 w-3 sm:h-3.5 sm:w-3.5'
|
||||
aria-hidden='true'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
119
src/features/chat/components/message-composer.tsx
Normal file
119
src/features/chat/components/message-composer.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
'use client';
|
||||
|
||||
import { FormEvent, useRef } from 'react';
|
||||
import { Icons } from '@/components/icons';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { FilePreview } from '@/components/ui/file-preview';
|
||||
import type { Attachment } from '../utils/types';
|
||||
|
||||
interface MessageComposerProps {
|
||||
draft: string;
|
||||
onDraftChange: (text: string) => void;
|
||||
onSubmit: (e: FormEvent<HTMLFormElement>) => void;
|
||||
contactName: string;
|
||||
quickReplies: string[];
|
||||
attachments: Attachment[];
|
||||
onAddAttachments: (files: FileList) => void;
|
||||
onRemoveAttachment: (id: string) => void;
|
||||
}
|
||||
|
||||
export function MessageComposer({
|
||||
draft,
|
||||
onDraftChange,
|
||||
onSubmit,
|
||||
contactName,
|
||||
quickReplies,
|
||||
attachments,
|
||||
onAddAttachments,
|
||||
onRemoveAttachment
|
||||
}: MessageComposerProps) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} className='space-y-2 sm:space-y-3' aria-label='Reply composer'>
|
||||
<label htmlFor='messenger-editor' className='sr-only'>
|
||||
Write a message
|
||||
</label>
|
||||
<div className='border-border/40 bg-background/80 flex items-end gap-2 rounded-2xl border p-3 backdrop-blur sm:gap-3 sm:rounded-3xl sm:p-4'>
|
||||
<div className='min-w-0 flex-1'>
|
||||
{attachments.length > 0 && (
|
||||
<FilePreview
|
||||
files={attachments.map((a) => ({
|
||||
id: a.id,
|
||||
name: a.name,
|
||||
type: a.type
|
||||
}))}
|
||||
onRemove={onRemoveAttachment}
|
||||
className='mb-1 p-0'
|
||||
/>
|
||||
)}
|
||||
<Textarea
|
||||
id='messenger-editor'
|
||||
value={draft}
|
||||
onChange={(e) => onDraftChange(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
if (draft.trim() || attachments.length > 0) {
|
||||
const form = e.currentTarget.closest('form');
|
||||
form?.requestSubmit();
|
||||
}
|
||||
}
|
||||
}}
|
||||
placeholder={'Message ' + contactName + ' (Enter to send, Shift+Enter for newline)'}
|
||||
rows={2}
|
||||
className='text-foreground placeholder:text-muted-foreground/70 min-h-[3rem] w-full resize-none border-none bg-transparent text-xs focus-visible:ring-0 focus-visible:outline-none sm:min-h-[4rem] sm:text-sm'
|
||||
aria-label={'Message ' + contactName}
|
||||
/>
|
||||
<div className='mt-2 flex flex-wrap gap-1.5 sm:mt-3 sm:gap-2'>
|
||||
{quickReplies.map((reply) => (
|
||||
<button
|
||||
key={reply}
|
||||
type='button'
|
||||
onClick={() => onDraftChange(reply)}
|
||||
className='border-border/50 bg-background/70 text-muted-foreground hover:border-primary/40 hover:text-foreground focus-visible:ring-primary/40 focus-visible:ring-offset-background rounded-full border px-2.5 py-0.5 text-[0.65rem] transition focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none sm:px-3 sm:py-1 sm:text-xs'
|
||||
>
|
||||
{reply}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex shrink-0 flex-col items-end gap-1.5 sm:w-24 sm:gap-2'>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type='file'
|
||||
multiple
|
||||
className='hidden'
|
||||
onChange={(e) => {
|
||||
if (e.target.files?.length) {
|
||||
onAddAttachments(e.target.files);
|
||||
}
|
||||
// Reset so same file can be re-selected
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='border-border/40 bg-background/70 text-muted-foreground hover:bg-muted/50 focus-visible:ring-primary/40 focus-visible:ring-offset-background size-8 rounded-full border transition focus-visible:ring-2 focus-visible:ring-offset-2 sm:size-10'
|
||||
aria-label='Attach a file'
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<Icons.paperclip className='h-3.5 w-3.5 sm:h-4 sm:w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
type='submit'
|
||||
size='icon'
|
||||
className='bg-primary text-primary-foreground hover:bg-primary/90 focus-visible:ring-primary/40 focus-visible:ring-offset-background size-8 rounded-full shadow-lg transition focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60 sm:size-10'
|
||||
disabled={!draft.trim() && attachments.length === 0}
|
||||
aria-label='Send message'
|
||||
>
|
||||
<Icons.send className='h-3.5 w-3.5 sm:h-4 sm:w-4' aria-hidden='true' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
129
src/features/chat/components/messenger.tsx
Normal file
129
src/features/chat/components/messenger.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
'use client';
|
||||
|
||||
import { FormEvent, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useReducedMotion } from 'motion/react';
|
||||
import { useChatStore } from '../utils/store';
|
||||
import type { Attachment, Message } from '../utils/types';
|
||||
import { ConversationList } from './conversation-list';
|
||||
import { ConversationSelect } from './conversation-select';
|
||||
import { ChatArea } from './chat-area';
|
||||
|
||||
export function Messenger() {
|
||||
const {
|
||||
conversations,
|
||||
selectedConversationId,
|
||||
draft,
|
||||
replyCursor,
|
||||
selectConversation,
|
||||
setDraft,
|
||||
sendMessage,
|
||||
addIncomingMessage,
|
||||
advanceReplyCursor,
|
||||
getActiveConversation
|
||||
} = useChatStore();
|
||||
|
||||
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const replyTimeoutRef = useRef<number | null>(null);
|
||||
const selectedRef = useRef(selectedConversationId);
|
||||
|
||||
useEffect(() => {
|
||||
selectedRef.current = selectedConversationId;
|
||||
setAttachments([]);
|
||||
}, [selectedConversationId]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (replyTimeoutRef.current) {
|
||||
window.clearTimeout(replyTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleAddAttachments = useCallback((files: FileList) => {
|
||||
const newAttachments: Attachment[] = Array.from(files).map((file) => ({
|
||||
id: 'file-' + Date.now() + '-' + Math.random().toString(36).slice(2, 7),
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type
|
||||
}));
|
||||
setAttachments((prev) => [...prev, ...newAttachments]);
|
||||
}, []);
|
||||
|
||||
const handleRemoveAttachment = useCallback((id: string) => {
|
||||
setAttachments((prev) => prev.filter((a) => a.id !== id));
|
||||
}, []);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const active = getActiveConversation();
|
||||
if ((!draft.trim() && attachments.length === 0) || !active) return;
|
||||
|
||||
const conversationId = active.id;
|
||||
sendMessage(draft, attachments.length > 0 ? attachments : undefined);
|
||||
setAttachments([]);
|
||||
|
||||
const autoReplies = active.autoReplies;
|
||||
if (!autoReplies.length) return;
|
||||
|
||||
const cursor = replyCursor[conversationId] ?? 0;
|
||||
const nextReply = autoReplies[cursor % autoReplies.length];
|
||||
const delay = shouldReduceMotion ? 0 : 900;
|
||||
|
||||
replyTimeoutRef.current = window.setTimeout(() => {
|
||||
const timestamp = new Date().toLocaleTimeString('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
const incoming: Message = {
|
||||
id: 'incoming-' + Date.now().toString(),
|
||||
sender: 'contact',
|
||||
author: active.name,
|
||||
text: nextReply,
|
||||
timestamp
|
||||
};
|
||||
|
||||
addIncomingMessage(conversationId, incoming);
|
||||
advanceReplyCursor(conversationId);
|
||||
}, delay);
|
||||
},
|
||||
[
|
||||
draft,
|
||||
attachments,
|
||||
replyCursor,
|
||||
shouldReduceMotion,
|
||||
getActiveConversation,
|
||||
sendMessage,
|
||||
addIncomingMessage,
|
||||
advanceReplyCursor
|
||||
]
|
||||
);
|
||||
|
||||
const activeConversation = getActiveConversation();
|
||||
if (!activeConversation) return null;
|
||||
|
||||
return (
|
||||
<div className='border-border/50 bg-background/70 relative grid h-[calc(100dvh-5.5rem)] w-full grid-rows-[auto,1fr] gap-3 overflow-hidden rounded-2xl border p-3 backdrop-blur-xl sm:gap-4 sm:p-4 lg:[grid-template-columns:30%_1fr] lg:grid-rows-[1fr] lg:gap-4 lg:rounded-3xl lg:p-5'>
|
||||
<ConversationSelect
|
||||
conversations={conversations}
|
||||
selectedId={selectedConversationId}
|
||||
onSelect={selectConversation}
|
||||
/>
|
||||
<ConversationList
|
||||
conversations={conversations}
|
||||
selectedId={selectedConversationId}
|
||||
onSelect={selectConversation}
|
||||
/>
|
||||
<ChatArea
|
||||
conversation={activeConversation}
|
||||
draft={draft}
|
||||
onDraftChange={setDraft}
|
||||
onSubmit={handleSubmit}
|
||||
attachments={attachments}
|
||||
onAddAttachments={handleAddAttachments}
|
||||
onRemoveAttachment={handleRemoveAttachment}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
127
src/features/chat/utils/data.ts
Normal file
127
src/features/chat/utils/data.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import type { Conversation } from './types';
|
||||
|
||||
export const initialConversations: Conversation[] = [
|
||||
{
|
||||
id: 'billing-issue',
|
||||
name: 'Alex from Support',
|
||||
title: 'Billing Issue #4821',
|
||||
status: 'online',
|
||||
unread: 2,
|
||||
initials: 'AS',
|
||||
messages: [
|
||||
{
|
||||
id: 'billing-1',
|
||||
sender: 'contact',
|
||||
author: 'Alex',
|
||||
text: "Hi there! I can see you were charged twice for the Pro plan this month. I've already initiated a refund for the duplicate charge.",
|
||||
timestamp: '10:02'
|
||||
},
|
||||
{
|
||||
id: 'billing-2',
|
||||
sender: 'user',
|
||||
author: 'You',
|
||||
text: 'Thanks for catching that. How long will the refund take to process?',
|
||||
timestamp: '10:05'
|
||||
},
|
||||
{
|
||||
id: 'billing-3',
|
||||
sender: 'contact',
|
||||
author: 'Alex',
|
||||
text: 'Typically 3-5 business days depending on your bank. You should see a pending credit within 24 hours though. Is there anything else I can help with?',
|
||||
timestamp: '10:08'
|
||||
}
|
||||
],
|
||||
quickReplies: [
|
||||
"That's perfect, thank you!",
|
||||
'Can I get a receipt for the refund?',
|
||||
'I also have a question about upgrading.'
|
||||
],
|
||||
autoReplies: [
|
||||
"You're welcome! I've also applied a 10% discount on your next billing cycle as an apology for the inconvenience.",
|
||||
'Absolutely — I just emailed the refund confirmation to your registered address.',
|
||||
"Of course! I'd be happy to walk you through the available plans."
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'api-integration',
|
||||
name: 'Priya from Engineering',
|
||||
title: 'API Integration Help',
|
||||
status: 'online',
|
||||
unread: 0,
|
||||
initials: 'PE',
|
||||
messages: [
|
||||
{
|
||||
id: 'api-1',
|
||||
sender: 'user',
|
||||
author: 'You',
|
||||
text: "I'm getting a 429 rate limit error when calling the /api/products endpoint. We're only making about 50 requests per minute.",
|
||||
timestamp: '09:15'
|
||||
},
|
||||
{
|
||||
id: 'api-2',
|
||||
sender: 'contact',
|
||||
author: 'Priya',
|
||||
text: "I checked your API key — it's on the Starter tier which has a 30 req/min limit. You'll need the Growth plan for 200 req/min. Would you like me to upgrade it?",
|
||||
timestamp: '09:18'
|
||||
},
|
||||
{
|
||||
id: 'api-3',
|
||||
sender: 'user',
|
||||
author: 'You',
|
||||
text: 'Yes please. Also, is there a way to implement retry logic that respects the Retry-After header?',
|
||||
timestamp: '09:22'
|
||||
},
|
||||
{
|
||||
id: 'api-4',
|
||||
sender: 'contact',
|
||||
author: 'Priya',
|
||||
text: "Great question — our SDK handles this automatically if you enable `autoRetry: true` in the config. I'll send you a code snippet.",
|
||||
timestamp: '09:25'
|
||||
}
|
||||
],
|
||||
quickReplies: [
|
||||
'That would be very helpful.',
|
||||
'Can you also share the rate limit docs?',
|
||||
"We're also seeing timeouts on the webhook endpoint."
|
||||
],
|
||||
autoReplies: [
|
||||
"Here's the code snippet — just add `autoRetry: true` and `maxRetries: 3` to your client config.",
|
||||
"Sure! I've shared the rate limiting guide in your inbox. It covers burst limits too.",
|
||||
"Let me check the webhook logs for your account. Can you share the endpoint URL you're using?"
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'account-access',
|
||||
name: 'Jordan from Security',
|
||||
title: 'Account Access Request',
|
||||
status: 'offline',
|
||||
unread: 1,
|
||||
initials: 'JS',
|
||||
messages: [
|
||||
{
|
||||
id: 'access-1',
|
||||
sender: 'contact',
|
||||
author: 'Jordan',
|
||||
text: "We noticed a login attempt from an unrecognized device in São Paulo. Was this you? We've temporarily locked the session as a precaution.",
|
||||
timestamp: 'Yesterday'
|
||||
},
|
||||
{
|
||||
id: 'access-2',
|
||||
sender: 'user',
|
||||
author: 'You',
|
||||
text: "No, that wasn't me. I'm based in New York. Can you revoke that session and enable 2FA on my account?",
|
||||
timestamp: 'Yesterday'
|
||||
}
|
||||
],
|
||||
quickReplies: [
|
||||
'Can I also see a list of all active sessions?',
|
||||
'Please reset my password as well.',
|
||||
'Has any data been accessed from that session?'
|
||||
],
|
||||
autoReplies: [
|
||||
"I've revoked all sessions except your current one and enabled 2FA. You'll get an email with the setup QR code.",
|
||||
"Done — you'll receive a password reset link shortly. Make sure to use a unique password.",
|
||||
'No data was accessed — the session was blocked before any API calls were made. Your account is secure.'
|
||||
]
|
||||
}
|
||||
];
|
||||
93
src/features/chat/utils/store.ts
Normal file
93
src/features/chat/utils/store.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { create } from 'zustand';
|
||||
// import { persist } from 'zustand/middleware';
|
||||
import type { Attachment, Conversation, Message } from './types';
|
||||
import { initialConversations } from './data';
|
||||
|
||||
type ReplyCursorState = Record<string, number>;
|
||||
|
||||
type ChatState = {
|
||||
conversations: Conversation[];
|
||||
selectedConversationId: string;
|
||||
draft: string;
|
||||
replyCursor: ReplyCursorState;
|
||||
|
||||
selectConversation: (id: string) => void;
|
||||
setDraft: (text: string) => void;
|
||||
sendMessage: (text: string, attachments?: Attachment[]) => void;
|
||||
addIncomingMessage: (conversationId: string, message: Message) => void;
|
||||
advanceReplyCursor: (conversationId: string) => void;
|
||||
getActiveConversation: () => Conversation | undefined;
|
||||
};
|
||||
|
||||
export const useChatStore = create<ChatState>()(
|
||||
// To enable persistence across refreshes, uncomment the persist wrapper below:
|
||||
// persist(
|
||||
(set, get) => ({
|
||||
conversations: initialConversations,
|
||||
selectedConversationId: initialConversations[0]?.id ?? '',
|
||||
draft: '',
|
||||
replyCursor: Object.fromEntries(initialConversations.map((c) => [c.id, 0])),
|
||||
|
||||
selectConversation: (id) =>
|
||||
set((state) => ({
|
||||
selectedConversationId: id,
|
||||
conversations: state.conversations.map((c) => (c.id === id ? { ...c, unread: 0 } : c))
|
||||
})),
|
||||
|
||||
setDraft: (text) => set({ draft: text }),
|
||||
|
||||
sendMessage: (text, attachments) => {
|
||||
const state = get();
|
||||
const timestamp = new Date().toLocaleTimeString('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
const outgoing: Message = {
|
||||
id: 'outgoing-' + Date.now().toString(),
|
||||
sender: 'user',
|
||||
author: 'You',
|
||||
text: text.trim(),
|
||||
timestamp,
|
||||
attachments: attachments?.length ? attachments : undefined
|
||||
};
|
||||
|
||||
set({
|
||||
draft: '',
|
||||
conversations: state.conversations.map((c) =>
|
||||
c.id === state.selectedConversationId
|
||||
? { ...c, messages: [...c.messages, outgoing], unread: 0 }
|
||||
: c
|
||||
)
|
||||
});
|
||||
},
|
||||
|
||||
addIncomingMessage: (conversationId, message) =>
|
||||
set((state) => ({
|
||||
conversations: state.conversations.map((c) => {
|
||||
if (c.id !== conversationId) return c;
|
||||
const isActive = state.selectedConversationId === conversationId;
|
||||
return {
|
||||
...c,
|
||||
messages: [...c.messages, message],
|
||||
unread: isActive ? 0 : c.unread + 1
|
||||
};
|
||||
})
|
||||
})),
|
||||
|
||||
advanceReplyCursor: (conversationId) =>
|
||||
set((state) => ({
|
||||
replyCursor: {
|
||||
...state.replyCursor,
|
||||
[conversationId]: (state.replyCursor[conversationId] ?? 0) + 1
|
||||
}
|
||||
})),
|
||||
|
||||
getActiveConversation: () => {
|
||||
const state = get();
|
||||
return state.conversations.find((c) => c.id === state.selectedConversationId);
|
||||
}
|
||||
})
|
||||
// ,
|
||||
// { name: 'chat' }
|
||||
// )
|
||||
);
|
||||
29
src/features/chat/utils/types.ts
Normal file
29
src/features/chat/utils/types.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
export type Attachment = {
|
||||
id: string;
|
||||
name: string;
|
||||
size: number;
|
||||
type: string;
|
||||
};
|
||||
|
||||
export type Message = {
|
||||
id: string;
|
||||
sender: 'user' | 'contact';
|
||||
author: string;
|
||||
text: string;
|
||||
timestamp: string;
|
||||
attachments?: Attachment[];
|
||||
};
|
||||
|
||||
export type ConversationStatus = 'online' | 'offline';
|
||||
|
||||
export type Conversation = {
|
||||
id: string;
|
||||
name: string;
|
||||
title: string;
|
||||
status: ConversationStatus;
|
||||
unread: number;
|
||||
initials: string;
|
||||
messages: Message[];
|
||||
quickReplies: string[];
|
||||
autoReplies: string[];
|
||||
};
|
||||
78
src/features/crm/api/mutations.ts
Normal file
78
src/features/crm/api/mutations.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { mutationOptions } from '@tanstack/react-query';
|
||||
import { getQueryClient } from '@/lib/query-client';
|
||||
import {
|
||||
approveQuotation,
|
||||
convertEnquiryToQuotation,
|
||||
createCustomer,
|
||||
createQuotationRevision,
|
||||
markQuotationStatus,
|
||||
rejectQuotation,
|
||||
revokeContactShare,
|
||||
shareContact,
|
||||
submitQuotationApproval,
|
||||
updateCustomer
|
||||
} from './service';
|
||||
import { crmKeys } from './queries';
|
||||
import type {
|
||||
ApprovalActionPayload,
|
||||
ContactSharePayload,
|
||||
CustomerMutationPayload,
|
||||
Quotation
|
||||
} from './types';
|
||||
|
||||
function invalidateCrm() {
|
||||
return getQueryClient().invalidateQueries({ queryKey: crmKeys.all });
|
||||
}
|
||||
|
||||
export const createCustomerMutation = mutationOptions({
|
||||
mutationFn: (payload: CustomerMutationPayload) => createCustomer(payload),
|
||||
onSuccess: invalidateCrm
|
||||
});
|
||||
|
||||
export const updateCustomerMutation = mutationOptions({
|
||||
mutationFn: ({ id, values }: { id: string; values: CustomerMutationPayload }) =>
|
||||
updateCustomer(id, values),
|
||||
onSuccess: invalidateCrm
|
||||
});
|
||||
|
||||
export const convertEnquiryMutation = mutationOptions({
|
||||
mutationFn: (enquiryId: string) => convertEnquiryToQuotation(enquiryId),
|
||||
onSuccess: invalidateCrm
|
||||
});
|
||||
|
||||
export const submitApprovalMutation = mutationOptions({
|
||||
mutationFn: (quotationId: string) => submitQuotationApproval(quotationId),
|
||||
onSuccess: invalidateCrm
|
||||
});
|
||||
|
||||
export const approveQuotationMutation = mutationOptions({
|
||||
mutationFn: (payload: ApprovalActionPayload) => approveQuotation(payload),
|
||||
onSuccess: invalidateCrm
|
||||
});
|
||||
|
||||
export const rejectQuotationMutation = mutationOptions({
|
||||
mutationFn: (payload: ApprovalActionPayload) => rejectQuotation(payload),
|
||||
onSuccess: invalidateCrm
|
||||
});
|
||||
|
||||
export const createQuotationRevisionMutation = mutationOptions({
|
||||
mutationFn: (quotationId: string) => createQuotationRevision(quotationId),
|
||||
onSuccess: invalidateCrm
|
||||
});
|
||||
|
||||
export const markQuotationStatusMutation = mutationOptions({
|
||||
mutationFn: ({ quotationId, status }: { quotationId: string; status: Quotation['status'] }) =>
|
||||
markQuotationStatus(quotationId, status),
|
||||
onSuccess: invalidateCrm
|
||||
});
|
||||
|
||||
export const shareContactMutation = mutationOptions({
|
||||
mutationFn: (payload: ContactSharePayload) => shareContact(payload),
|
||||
onSuccess: invalidateCrm
|
||||
});
|
||||
|
||||
export const revokeContactShareMutation = mutationOptions({
|
||||
mutationFn: ({ contactId, shareId }: { contactId: string; shareId: string }) =>
|
||||
revokeContactShare(contactId, shareId),
|
||||
onSuccess: invalidateCrm
|
||||
});
|
||||
85
src/features/crm/api/queries.ts
Normal file
85
src/features/crm/api/queries.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { queryOptions } from '@tanstack/react-query';
|
||||
import {
|
||||
getApprovals,
|
||||
getCrmDashboardData,
|
||||
getCrmReferenceData,
|
||||
getCustomerById,
|
||||
getCustomers,
|
||||
getEnquiries,
|
||||
getEnquiryById,
|
||||
getQuotationById,
|
||||
getQuotations
|
||||
} from './service';
|
||||
import type {
|
||||
ApprovalFilters,
|
||||
CustomerFilters,
|
||||
EnquiryFilters,
|
||||
QuotationFilters
|
||||
} from './types';
|
||||
|
||||
export const crmKeys = {
|
||||
all: ['crm'] as const,
|
||||
references: () => [...crmKeys.all, 'references'] as const,
|
||||
dashboard: () => [...crmKeys.all, 'dashboard'] as const,
|
||||
customers: (filters: CustomerFilters) => [...crmKeys.all, 'customers', filters] as const,
|
||||
customer: (id: string) => [...crmKeys.all, 'customer', id] as const,
|
||||
enquiries: (filters: EnquiryFilters) => [...crmKeys.all, 'enquiries', filters] as const,
|
||||
enquiry: (id: string) => [...crmKeys.all, 'enquiry', id] as const,
|
||||
quotations: (filters: QuotationFilters) => [...crmKeys.all, 'quotations', filters] as const,
|
||||
quotation: (id: string) => [...crmKeys.all, 'quotation', id] as const,
|
||||
approvals: (filters: ApprovalFilters) => [...crmKeys.all, 'approvals', filters] as const
|
||||
};
|
||||
|
||||
export const crmReferenceQueryOptions = () =>
|
||||
queryOptions({
|
||||
queryKey: crmKeys.references(),
|
||||
queryFn: getCrmReferenceData
|
||||
});
|
||||
|
||||
export const crmDashboardQueryOptions = () =>
|
||||
queryOptions({
|
||||
queryKey: crmKeys.dashboard(),
|
||||
queryFn: getCrmDashboardData
|
||||
});
|
||||
|
||||
export const customersQueryOptions = (filters: CustomerFilters) =>
|
||||
queryOptions({
|
||||
queryKey: crmKeys.customers(filters),
|
||||
queryFn: () => getCustomers(filters)
|
||||
});
|
||||
|
||||
export const customerByIdOptions = (id: string) =>
|
||||
queryOptions({
|
||||
queryKey: crmKeys.customer(id),
|
||||
queryFn: () => getCustomerById(id)
|
||||
});
|
||||
|
||||
export const enquiriesQueryOptions = (filters: EnquiryFilters) =>
|
||||
queryOptions({
|
||||
queryKey: crmKeys.enquiries(filters),
|
||||
queryFn: () => getEnquiries(filters)
|
||||
});
|
||||
|
||||
export const enquiryByIdOptions = (id: string) =>
|
||||
queryOptions({
|
||||
queryKey: crmKeys.enquiry(id),
|
||||
queryFn: () => getEnquiryById(id)
|
||||
});
|
||||
|
||||
export const quotationsQueryOptions = (filters: QuotationFilters) =>
|
||||
queryOptions({
|
||||
queryKey: crmKeys.quotations(filters),
|
||||
queryFn: () => getQuotations(filters)
|
||||
});
|
||||
|
||||
export const quotationByIdOptions = (id: string) =>
|
||||
queryOptions({
|
||||
queryKey: crmKeys.quotation(id),
|
||||
queryFn: () => getQuotationById(id)
|
||||
});
|
||||
|
||||
export const approvalsQueryOptions = (filters: ApprovalFilters) =>
|
||||
queryOptions({
|
||||
queryKey: crmKeys.approvals(filters),
|
||||
queryFn: () => getApprovals(filters)
|
||||
});
|
||||
474
src/features/crm/api/service.ts
Normal file
474
src/features/crm/api/service.ts
Normal file
@@ -0,0 +1,474 @@
|
||||
import type {
|
||||
ApprovalActionPayload,
|
||||
ApprovalFilters,
|
||||
ContactSharePayload,
|
||||
CrmDashboardData,
|
||||
CrmReferenceData,
|
||||
Customer,
|
||||
CustomerDetailResponse,
|
||||
CustomerFilters,
|
||||
CustomerMutationPayload,
|
||||
Enquiry,
|
||||
EnquiryDetailResponse,
|
||||
EnquiryFilters,
|
||||
PaginatedResponse,
|
||||
Quotation,
|
||||
QuotationDetailResponse,
|
||||
QuotationFilters
|
||||
} from './types';
|
||||
import { initialCrmState, type CrmMockState } from '../data/mock-crm';
|
||||
|
||||
function clone<T>(value: T): T {
|
||||
return JSON.parse(JSON.stringify(value)) as T;
|
||||
}
|
||||
|
||||
let crmState: CrmMockState = clone(initialCrmState);
|
||||
|
||||
function sortItems<T>(items: T[], sort?: string) {
|
||||
if (!sort) return items;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(sort) as Array<{ id: string; desc?: boolean }>;
|
||||
const [rule] = parsed;
|
||||
|
||||
if (!rule) return items;
|
||||
|
||||
return [...items].sort((a, b) => {
|
||||
const av = String((a as Record<string, unknown>)[rule.id] ?? '');
|
||||
const bv = String((b as Record<string, unknown>)[rule.id] ?? '');
|
||||
return rule.desc ? bv.localeCompare(av) : av.localeCompare(bv);
|
||||
});
|
||||
} catch {
|
||||
return items;
|
||||
}
|
||||
}
|
||||
|
||||
function paginate<T>(items: T[], page = 1, limit = 10): PaginatedResponse<T> {
|
||||
const start = (page - 1) * limit;
|
||||
return {
|
||||
items: items.slice(start, start + limit),
|
||||
totalItems: items.length
|
||||
};
|
||||
}
|
||||
|
||||
function findBranch(branchId: string) {
|
||||
return crmState.branches.find((branch) => branch.id === branchId);
|
||||
}
|
||||
|
||||
function findSalesperson(id: string) {
|
||||
return crmState.salespersons.find((sales) => sales.id === id);
|
||||
}
|
||||
|
||||
export async function getCrmReferenceData(): Promise<CrmReferenceData> {
|
||||
return clone({
|
||||
branches: crmState.branches,
|
||||
salespersons: crmState.salespersons,
|
||||
masterOptions: crmState.masterOptions,
|
||||
sequences: crmState.sequences,
|
||||
templates: crmState.templates
|
||||
});
|
||||
}
|
||||
|
||||
export async function getCrmDashboardData(): Promise<CrmDashboardData> {
|
||||
const openEnquiries = crmState.enquiries.filter((enquiry) =>
|
||||
['new', 'qualifying', 'requirement', 'follow_up'].includes(enquiry.status)
|
||||
);
|
||||
const pendingQuotations = crmState.quotations.filter(
|
||||
(quotation) => quotation.status === 'pending_approval'
|
||||
);
|
||||
const wonQuotations = crmState.quotations.filter((quotation) => quotation.status === 'accepted');
|
||||
const lostQuotations = crmState.quotations.filter((quotation) => quotation.status === 'lost');
|
||||
const hotQuotations = crmState.quotations.filter((quotation) => quotation.isHotProject);
|
||||
const dueFollowUps = crmState.quotations.flatMap((quotation) => quotation.followUps).slice(0, 5);
|
||||
const conversionRate = Math.round(
|
||||
(wonQuotations.length / Math.max(1, crmState.enquiries.length)) * 100
|
||||
);
|
||||
|
||||
return clone({
|
||||
metrics: [
|
||||
{ id: 'm-1', label: 'Total Enquiries', value: String(crmState.enquiries.length), change: '+12%', trend: 'up' },
|
||||
{ id: 'm-2', label: 'Open Enquiries', value: String(openEnquiries.length), change: '+3', trend: 'up' },
|
||||
{ id: 'm-3', label: 'Pending Quotations', value: String(pendingQuotations.length), change: '+2', trend: 'up' },
|
||||
{ id: 'm-4', label: 'Pending Approval', value: String(crmState.approvals.filter((item) => item.status === 'pending').length), change: 'Needs review', trend: 'flat' },
|
||||
{ id: 'm-5', label: 'Won Amount', value: `${Math.round(wonQuotations.reduce((sum, item) => sum + item.totalAmount, 0) / 1000000)}M`, change: '+18%', trend: 'up' },
|
||||
{ id: 'm-6', label: 'Lost Amount', value: `${Math.round(lostQuotations.reduce((sum, item) => sum + item.totalAmount, 0) / 1000000)}M`, change: '-6%', trend: 'down' },
|
||||
{ id: 'm-7', label: 'Conversion Rate', value: `${conversionRate}%`, change: '+4pts', trend: 'up' },
|
||||
{ id: 'm-8', label: 'Hot Projects', value: String(hotQuotations.length), change: 'Focus', trend: 'flat' },
|
||||
{ id: 'm-9', label: 'Follow-up Due Today', value: String(dueFollowUps.length), change: 'Today', trend: 'flat' }
|
||||
],
|
||||
enquiryPipeline: [
|
||||
{ label: 'New', value: crmState.enquiries.filter((item) => item.status === 'new').length },
|
||||
{ label: 'Qualifying', value: crmState.enquiries.filter((item) => item.status === 'qualifying').length },
|
||||
{ label: 'Requirement', value: crmState.enquiries.filter((item) => item.status === 'requirement').length },
|
||||
{ label: 'Follow Up', value: crmState.enquiries.filter((item) => item.status === 'follow_up').length },
|
||||
{ label: 'Converted', value: crmState.enquiries.filter((item) => item.status === 'converted').length }
|
||||
],
|
||||
quotationPipeline: [
|
||||
{ label: 'Draft', value: crmState.quotations.filter((item) => item.status === 'draft').length },
|
||||
{ label: 'Pending', value: crmState.quotations.filter((item) => item.status === 'pending_approval').length },
|
||||
{ label: 'Sent', value: crmState.quotations.filter((item) => item.status === 'sent').length },
|
||||
{ label: 'Accepted', value: crmState.quotations.filter((item) => item.status === 'accepted').length },
|
||||
{ label: 'Lost', value: crmState.quotations.filter((item) => item.status === 'lost').length }
|
||||
],
|
||||
salesPerformance: crmState.salespersons.map((salesperson) => ({
|
||||
label: salesperson.nickname,
|
||||
value: crmState.quotations.filter((quotation) => quotation.salesmanId === salesperson.id).length,
|
||||
secondaryValue: crmState.quotations
|
||||
.filter((quotation) => quotation.salesmanId === salesperson.id)
|
||||
.reduce((sum, quotation) => sum + quotation.totalAmount, 0)
|
||||
})),
|
||||
competitorAnalysis: [
|
||||
{ label: 'Apex Dock', value: 3 },
|
||||
{ label: 'LiftPro', value: 2 },
|
||||
{ label: 'SunNorth', value: 1 },
|
||||
{ label: 'Others', value: 2 }
|
||||
],
|
||||
wonLostTrend: [
|
||||
{ label: 'Jan', value: 1, secondaryValue: 0 },
|
||||
{ label: 'Feb', value: 2, secondaryValue: 1 },
|
||||
{ label: 'Mar', value: 1, secondaryValue: 1 },
|
||||
{ label: 'Apr', value: 3, secondaryValue: 1 },
|
||||
{ label: 'May', value: 2, secondaryValue: 2 },
|
||||
{ label: 'Jun', value: 2, secondaryValue: 1 }
|
||||
],
|
||||
pendingApprovals: crmState.approvals.filter((item) => item.status === 'pending'),
|
||||
hotQuotations,
|
||||
dueFollowUps
|
||||
});
|
||||
}
|
||||
|
||||
export async function getCustomers(filters: CustomerFilters): Promise<PaginatedResponse<Customer>> {
|
||||
let items = [...crmState.customers];
|
||||
|
||||
if (filters.search) {
|
||||
const search = filters.search.toLowerCase();
|
||||
items = items.filter(
|
||||
(customer) =>
|
||||
customer.name.toLowerCase().includes(search) || customer.code.toLowerCase().includes(search)
|
||||
);
|
||||
}
|
||||
|
||||
if (filters.status) {
|
||||
items = items.filter((customer) => customer.customerStatus === filters.status);
|
||||
}
|
||||
|
||||
if (filters.branch) {
|
||||
items = items.filter((customer) => customer.branchId === filters.branch);
|
||||
}
|
||||
|
||||
items = sortItems(items, filters.sort);
|
||||
return clone(paginate(items, filters.page, filters.limit));
|
||||
}
|
||||
|
||||
export async function getCustomerById(id: string): Promise<CustomerDetailResponse> {
|
||||
const customer = crmState.customers.find((item) => item.id === id);
|
||||
if (!customer) {
|
||||
throw new Error('Customer not found');
|
||||
}
|
||||
|
||||
return clone({
|
||||
customer,
|
||||
contacts: crmState.contacts.filter((contact) => contact.customerId === id),
|
||||
shares: crmState.contactShares.filter((share) =>
|
||||
crmState.contacts.some((contact) => contact.id === share.contactId && contact.customerId === id)
|
||||
),
|
||||
shareLogs: crmState.contactShareLogs.filter((log) =>
|
||||
crmState.contacts.some((contact) => contact.id === log.contactId && contact.customerId === id)
|
||||
),
|
||||
enquiries: crmState.enquiries.filter((enquiry) => enquiry.customerId === id),
|
||||
quotations: crmState.quotations.filter((quotation) =>
|
||||
quotation.customerRoles.some((role) => role.customerId === id)
|
||||
),
|
||||
auditEvents: crmState.auditEvents.filter((event) => event.entityId === id),
|
||||
branch: findBranch(customer.branchId)!
|
||||
});
|
||||
}
|
||||
|
||||
export async function getEnquiries(filters: EnquiryFilters): Promise<PaginatedResponse<Enquiry>> {
|
||||
let items = [...crmState.enquiries];
|
||||
|
||||
if (filters.search) {
|
||||
const search = filters.search.toLowerCase();
|
||||
items = items.filter(
|
||||
(item) => item.code.toLowerCase().includes(search) || item.title.toLowerCase().includes(search)
|
||||
);
|
||||
}
|
||||
if (filters.status) items = items.filter((item) => item.status === filters.status);
|
||||
if (filters.productType) items = items.filter((item) => item.productType === filters.productType);
|
||||
if (filters.salesman) items = items.filter((item) => item.salesmanId === filters.salesman);
|
||||
if (filters.dateFrom) items = items.filter((item) => item.createdAt >= filters.dateFrom!);
|
||||
if (filters.dateTo) items = items.filter((item) => item.createdAt <= `${filters.dateTo!}T23:59:59.999Z`);
|
||||
|
||||
items = sortItems(items, filters.sort);
|
||||
return clone(paginate(items, filters.page, filters.limit));
|
||||
}
|
||||
|
||||
export async function getEnquiryById(id: string): Promise<EnquiryDetailResponse> {
|
||||
const enquiry = crmState.enquiries.find((item) => item.id === id);
|
||||
if (!enquiry) {
|
||||
throw new Error('Enquiry not found');
|
||||
}
|
||||
|
||||
const customer = crmState.customers.find((item) => item.id === enquiry.customerId)!;
|
||||
const contact = crmState.contacts.find((item) => item.id === enquiry.contactId)!;
|
||||
return clone({
|
||||
enquiry,
|
||||
customer,
|
||||
contact,
|
||||
quotations: crmState.quotations.filter((quotation) => quotation.enquiryId === id),
|
||||
branch: findBranch(enquiry.branchId)!,
|
||||
salesman: findSalesperson(enquiry.salesmanId)!
|
||||
});
|
||||
}
|
||||
|
||||
export async function getQuotations(
|
||||
filters: QuotationFilters
|
||||
): Promise<PaginatedResponse<Quotation>> {
|
||||
let items = [...crmState.quotations];
|
||||
|
||||
if (filters.search) {
|
||||
const search = filters.search.toLowerCase();
|
||||
items = items.filter(
|
||||
(quotation) =>
|
||||
quotation.code.toLowerCase().includes(search) ||
|
||||
quotation.project.toLowerCase().includes(search)
|
||||
);
|
||||
}
|
||||
if (filters.status) items = items.filter((quotation) => quotation.status === filters.status);
|
||||
if (filters.quotationType) items = items.filter((quotation) => quotation.quotationType === filters.quotationType);
|
||||
if (filters.salesman) items = items.filter((quotation) => quotation.salesmanId === filters.salesman);
|
||||
if (filters.hot) items = items.filter((quotation) => (filters.hot === 'yes' ? quotation.isHotProject : !quotation.isHotProject));
|
||||
if (filters.dateFrom) items = items.filter((quotation) => quotation.quotationDate >= filters.dateFrom!);
|
||||
if (filters.dateTo) items = items.filter((quotation) => quotation.quotationDate <= filters.dateTo!);
|
||||
|
||||
items = sortItems(items, filters.sort);
|
||||
return clone(paginate(items, filters.page, filters.limit));
|
||||
}
|
||||
|
||||
export async function getQuotationById(id: string): Promise<QuotationDetailResponse> {
|
||||
const quotation = crmState.quotations.find((item) => item.id === id);
|
||||
if (!quotation) {
|
||||
throw new Error('Quotation not found');
|
||||
}
|
||||
|
||||
const enquiry = crmState.enquiries.find((item) => item.id === quotation.enquiryId)!;
|
||||
const customerIds = [...new Set(quotation.customerRoles.map((role) => role.customerId))];
|
||||
const contactIds = [...new Set(quotation.customerRoles.map((role) => role.contactId).filter(Boolean))] as string[];
|
||||
|
||||
return clone({
|
||||
quotation,
|
||||
enquiry,
|
||||
customers: crmState.customers.filter((item) => customerIds.includes(item.id)),
|
||||
contacts: crmState.contacts.filter((item) => contactIds.includes(item.id)),
|
||||
branch: findBranch(quotation.branchId)!,
|
||||
salesman: findSalesperson(quotation.salesmanId)!,
|
||||
saleAdmin: findSalesperson(quotation.saleAdminId)!,
|
||||
auditEvents: crmState.auditEvents.filter((event) => quotation.auditEventIds.includes(event.id))
|
||||
});
|
||||
}
|
||||
|
||||
export async function getApprovals(filters: ApprovalFilters) {
|
||||
let items = [...crmState.approvals];
|
||||
if (filters.branch) items = items.filter((item) => item.branchId === filters.branch);
|
||||
if (filters.status) items = items.filter((item) => item.status === filters.status);
|
||||
return clone(paginate(items, filters.page, filters.limit));
|
||||
}
|
||||
|
||||
export async function createCustomer(values: CustomerMutationPayload) {
|
||||
const customer: Customer = { id: `cus-${Date.now()}`, contactIds: [], ...values };
|
||||
crmState.customers.unshift(customer);
|
||||
return clone(customer);
|
||||
}
|
||||
|
||||
export async function updateCustomer(id: string, values: CustomerMutationPayload) {
|
||||
crmState.customers = crmState.customers.map((customer) =>
|
||||
customer.id === id ? { ...customer, ...values } : customer
|
||||
);
|
||||
return getCustomerById(id);
|
||||
}
|
||||
|
||||
export async function convertEnquiryToQuotation(enquiryId: string) {
|
||||
const enquiry = crmState.enquiries.find((item) => item.id === enquiryId);
|
||||
if (!enquiry) throw new Error('Enquiry not found');
|
||||
|
||||
const nextId = `qt-${Date.now()}`;
|
||||
const newQuotation: Quotation = {
|
||||
id: nextId,
|
||||
code: `QT2606-${String(crmState.quotations.length + 1).padStart(3, '0')}`,
|
||||
enquiryId,
|
||||
quotationDate: '2026-06-11',
|
||||
validUntil: '2026-07-11',
|
||||
quotationType: 'official',
|
||||
project: enquiry.title,
|
||||
siteLocation: enquiry.projectLocation,
|
||||
customerRoles: [{ role: 'owner', customerId: enquiry.customerId, contactId: enquiry.contactId }],
|
||||
salesmanId: enquiry.salesmanId,
|
||||
saleAdminId: 'sp-2',
|
||||
branchId: enquiry.branchId,
|
||||
status: 'draft',
|
||||
revision: 0,
|
||||
totalAmount: enquiry.expectedValue,
|
||||
chancePercent: enquiry.chancePercent,
|
||||
isHotProject: enquiry.chancePercent >= 70,
|
||||
items: [{ id: `${nextId}-item-1`, topic: 'Scope', description: enquiry.requirementSummary, quantity: 1, unit: 'lot', unitPrice: enquiry.expectedValue, amount: enquiry.expectedValue }],
|
||||
topics: [
|
||||
{ id: `${nextId}-topic-1`, type: 'scope', label: 'Scope', items: [enquiry.requirementSummary] },
|
||||
{ id: `${nextId}-topic-2`, type: 'exclusion', label: 'Exclusion', items: ['To be confirmed'] },
|
||||
{ id: `${nextId}-topic-3`, type: 'payment', label: 'Payment', items: ['Pending setup'] }
|
||||
],
|
||||
followUps: [],
|
||||
attachments: [],
|
||||
approvalSteps: [],
|
||||
auditEventIds: []
|
||||
};
|
||||
|
||||
crmState.quotations.unshift(newQuotation);
|
||||
crmState.enquiries = crmState.enquiries.map((item) =>
|
||||
item.id === enquiryId
|
||||
? {
|
||||
...item,
|
||||
status: 'converted',
|
||||
quotationIds: [...item.quotationIds, nextId],
|
||||
activities: [
|
||||
...item.activities,
|
||||
{
|
||||
id: `${enquiryId}-activity-${Date.now()}`,
|
||||
type: 'CONVERT',
|
||||
title: 'Convert to quotation',
|
||||
detail: `สร้าง ${newQuotation.code}`,
|
||||
actorName: 'CRM Demo',
|
||||
createdAt: '2026-06-11T10:00:00.000Z'
|
||||
}
|
||||
]
|
||||
}
|
||||
: item
|
||||
);
|
||||
|
||||
return clone(newQuotation);
|
||||
}
|
||||
|
||||
export async function submitQuotationApproval(quotationId: string) {
|
||||
crmState.quotations = crmState.quotations.map((quotation) =>
|
||||
quotation.id === quotationId
|
||||
? {
|
||||
...quotation,
|
||||
status: 'pending_approval',
|
||||
approvalSteps:
|
||||
quotation.approvalSteps.length > 0
|
||||
? quotation.approvalSteps
|
||||
: [
|
||||
{ id: `${quotationId}-ap-1`, level: 1, approverName: 'Sales Director', approverPosition: 'Director', status: 'pending' }
|
||||
]
|
||||
}
|
||||
: quotation
|
||||
);
|
||||
}
|
||||
|
||||
export async function approveQuotation(payload: ApprovalActionPayload) {
|
||||
crmState.quotations = crmState.quotations.map((quotation) => {
|
||||
if (quotation.id !== payload.quotationId) return quotation;
|
||||
|
||||
const nextSteps = quotation.approvalSteps.map((step, index) =>
|
||||
index === quotation.approvalSteps.findIndex((item) => item.status === 'pending')
|
||||
? {
|
||||
...step,
|
||||
status: 'approved',
|
||||
actedAt: '2026-06-11T11:00:00.000Z',
|
||||
comment: payload.comment
|
||||
}
|
||||
: step
|
||||
);
|
||||
|
||||
const hasPending = nextSteps.some((step) => step.status === 'pending');
|
||||
return {
|
||||
...quotation,
|
||||
approvalSteps: nextSteps,
|
||||
status: hasPending ? 'pending_approval' : 'approved',
|
||||
approvedPdfUrl: hasPending ? quotation.approvedPdfUrl : `/mock/${quotation.code.toLowerCase()}.pdf`
|
||||
};
|
||||
});
|
||||
|
||||
crmState.approvals = crmState.approvals.map((item) =>
|
||||
item.quotationId === payload.quotationId ? { ...item, status: 'approved' } : item
|
||||
);
|
||||
}
|
||||
|
||||
export async function rejectQuotation(payload: ApprovalActionPayload) {
|
||||
crmState.quotations = crmState.quotations.map((quotation) =>
|
||||
quotation.id === payload.quotationId
|
||||
? {
|
||||
...quotation,
|
||||
status: 'rejected',
|
||||
approvalSteps: quotation.approvalSteps.map((step, index) =>
|
||||
index === quotation.approvalSteps.findIndex((item) => item.status === 'pending')
|
||||
? {
|
||||
...step,
|
||||
status: 'rejected',
|
||||
actedAt: '2026-06-11T11:30:00.000Z',
|
||||
comment: payload.comment
|
||||
}
|
||||
: step
|
||||
)
|
||||
}
|
||||
: quotation
|
||||
);
|
||||
crmState.approvals = crmState.approvals.map((item) =>
|
||||
item.quotationId === payload.quotationId ? { ...item, status: 'rejected' } : item
|
||||
);
|
||||
}
|
||||
|
||||
export async function createQuotationRevision(quotationId: string) {
|
||||
const quotation = crmState.quotations.find((item) => item.id === quotationId);
|
||||
if (!quotation) throw new Error('Quotation not found');
|
||||
|
||||
const revisionNumber = quotation.revision + 1;
|
||||
const newQuotation: Quotation = {
|
||||
...clone(quotation),
|
||||
id: `${quotationId}-rev-${revisionNumber}`,
|
||||
code: `${quotation.code}-R${String(revisionNumber).padStart(2, '0')}`,
|
||||
revision: revisionNumber,
|
||||
status: 'revised',
|
||||
totalAmount: quotation.totalAmount + 125000
|
||||
};
|
||||
|
||||
crmState.quotations.unshift(newQuotation);
|
||||
return clone(newQuotation);
|
||||
}
|
||||
|
||||
export async function markQuotationStatus(quotationId: string, status: Quotation['status']) {
|
||||
crmState.quotations = crmState.quotations.map((quotation) =>
|
||||
quotation.id === quotationId ? { ...quotation, status } : quotation
|
||||
);
|
||||
}
|
||||
|
||||
export async function shareContact(payload: ContactSharePayload) {
|
||||
const share = {
|
||||
id: `share-${Date.now()}`,
|
||||
createdAt: '2026-06-11T12:00:00.000Z',
|
||||
...payload
|
||||
};
|
||||
crmState.contactShares.unshift(share);
|
||||
crmState.contactShareLogs.unshift({
|
||||
id: `share-log-${Date.now()}`,
|
||||
contactId: payload.contactId,
|
||||
action: 'SHARE',
|
||||
targetUserName: payload.sharedWithUserName,
|
||||
actorName: 'CRM Demo',
|
||||
createdAt: '2026-06-11T12:00:00.000Z'
|
||||
});
|
||||
}
|
||||
|
||||
export async function revokeContactShare(contactId: string, shareId: string) {
|
||||
const current = crmState.contactShares.find((share) => share.id === shareId);
|
||||
crmState.contactShares = crmState.contactShares.filter((share) => share.id !== shareId);
|
||||
if (current) {
|
||||
crmState.contactShareLogs.unshift({
|
||||
id: `share-log-${Date.now()}`,
|
||||
contactId,
|
||||
action: 'REVOKE',
|
||||
targetUserName: current.sharedWithUserName,
|
||||
actorName: 'CRM Demo',
|
||||
createdAt: '2026-06-11T12:30:00.000Z'
|
||||
});
|
||||
}
|
||||
}
|
||||
392
src/features/crm/api/types.ts
Normal file
392
src/features/crm/api/types.ts
Normal file
@@ -0,0 +1,392 @@
|
||||
export type CrmStatusTone = 'default' | 'secondary' | 'outline' | 'destructive';
|
||||
|
||||
export interface CrmBranch {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
region: string;
|
||||
}
|
||||
|
||||
export interface CrmSalesperson {
|
||||
id: string;
|
||||
name: string;
|
||||
nickname: string;
|
||||
branchId: string;
|
||||
role: 'sales' | 'sale_admin' | 'manager';
|
||||
}
|
||||
|
||||
export interface CustomerContact {
|
||||
id: string;
|
||||
customerId: string;
|
||||
name: string;
|
||||
position: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
lineId?: string;
|
||||
isPrimary?: boolean;
|
||||
}
|
||||
|
||||
export interface ContactShareLog {
|
||||
id: string;
|
||||
contactId: string;
|
||||
action: 'SHARE' | 'REVOKE';
|
||||
targetUserName: string;
|
||||
actorName: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface ContactShare {
|
||||
id: string;
|
||||
contactId: string;
|
||||
sharedWithUserId: string;
|
||||
sharedWithUserName: string;
|
||||
permission: 'view' | 'edit';
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface Customer {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
taxId: string;
|
||||
address: string;
|
||||
province: string;
|
||||
district: string;
|
||||
subDistrict: string;
|
||||
postalCode: string;
|
||||
phone: string;
|
||||
fax?: string;
|
||||
email: string;
|
||||
customerType: 'developer' | 'contractor' | 'owner' | 'consultant';
|
||||
customerStatus: 'active' | 'prospect' | 'inactive';
|
||||
branchId: string;
|
||||
contactIds: string[];
|
||||
}
|
||||
|
||||
export type EnquiryStatus =
|
||||
| 'new'
|
||||
| 'qualifying'
|
||||
| 'requirement'
|
||||
| 'follow_up'
|
||||
| 'converted'
|
||||
| 'closed_lost'
|
||||
| 'cancelled';
|
||||
|
||||
export interface EnquiryActivity {
|
||||
id: string;
|
||||
type: 'CALL' | 'MEETING' | 'VISIT' | 'NOTE' | 'UPDATE' | 'CONVERT';
|
||||
title: string;
|
||||
detail: string;
|
||||
actorName: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface Enquiry {
|
||||
id: string;
|
||||
code: string;
|
||||
title: string;
|
||||
customerId: string;
|
||||
contactId: string;
|
||||
branchId: string;
|
||||
salesmanId: string;
|
||||
productType: 'crane' | 'dockdoor' | 'solarcell';
|
||||
status: EnquiryStatus;
|
||||
requirementSummary: string;
|
||||
projectLocation: string;
|
||||
chancePercent: number;
|
||||
competitor: string;
|
||||
expectedValue: number;
|
||||
dueDate: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
quotationIds: string[];
|
||||
activities: EnquiryActivity[];
|
||||
}
|
||||
|
||||
export type QuotationStatus =
|
||||
| 'draft'
|
||||
| 'pending_approval'
|
||||
| 'approved'
|
||||
| 'rejected'
|
||||
| 'sent'
|
||||
| 'accepted'
|
||||
| 'lost'
|
||||
| 'cancelled'
|
||||
| 'revised';
|
||||
|
||||
export interface QuotationCustomerRole {
|
||||
role: 'owner' | 'consultant' | 'contractor' | 'billing';
|
||||
customerId: string;
|
||||
contactId?: string;
|
||||
}
|
||||
|
||||
export interface QuotationItem {
|
||||
id: string;
|
||||
topic: string;
|
||||
description: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
unitPrice: number;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
export interface QuotationTopic {
|
||||
id: string;
|
||||
type: 'scope' | 'exclusion' | 'payment';
|
||||
label: string;
|
||||
items: string[];
|
||||
}
|
||||
|
||||
export interface QuotationFollowUp {
|
||||
id: string;
|
||||
title: string;
|
||||
dueDate: string;
|
||||
ownerName: string;
|
||||
status: 'open' | 'done';
|
||||
}
|
||||
|
||||
export interface QuotationAttachment {
|
||||
id: string;
|
||||
fileName: string;
|
||||
fileType: 'pdf' | 'doc' | 'xls' | 'zip';
|
||||
uploadedAt: string;
|
||||
}
|
||||
|
||||
export interface ApprovalStep {
|
||||
id: string;
|
||||
level: number;
|
||||
approverName: string;
|
||||
approverPosition: string;
|
||||
status: 'pending' | 'approved' | 'rejected';
|
||||
actedAt?: string;
|
||||
comment?: string;
|
||||
}
|
||||
|
||||
export interface Quotation {
|
||||
id: string;
|
||||
code: string;
|
||||
enquiryId: string;
|
||||
quotationDate: string;
|
||||
validUntil: string;
|
||||
quotationType: 'budgetary' | 'official' | 'service';
|
||||
project: string;
|
||||
siteLocation: string;
|
||||
customerRoles: QuotationCustomerRole[];
|
||||
salesmanId: string;
|
||||
saleAdminId: string;
|
||||
branchId: string;
|
||||
status: QuotationStatus;
|
||||
revision: number;
|
||||
totalAmount: number;
|
||||
chancePercent: number;
|
||||
isHotProject: boolean;
|
||||
approvedPdfUrl?: string;
|
||||
approvalSnapshot?: string;
|
||||
items: QuotationItem[];
|
||||
topics: QuotationTopic[];
|
||||
followUps: QuotationFollowUp[];
|
||||
attachments: QuotationAttachment[];
|
||||
approvalSteps: ApprovalStep[];
|
||||
auditEventIds: string[];
|
||||
}
|
||||
|
||||
export interface ApprovalItem {
|
||||
id: string;
|
||||
quotationId: string;
|
||||
quotationCode: string;
|
||||
amount: number;
|
||||
productType: string;
|
||||
branchId: string;
|
||||
submitterName: string;
|
||||
approverLevel: number;
|
||||
status: 'pending' | 'approved' | 'rejected';
|
||||
}
|
||||
|
||||
export interface AuditEvent {
|
||||
id: string;
|
||||
entityType: 'customer' | 'enquiry' | 'quotation' | 'approval';
|
||||
entityId: string;
|
||||
action: 'CREATE' | 'UPDATE' | 'SEND' | 'APPROVE' | 'REJECT' | 'CONVERT' | 'REVISE';
|
||||
actorName: string;
|
||||
detail: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface DocumentSequence {
|
||||
id: string;
|
||||
documentType: 'enquiry' | 'quotation' | 'quotation_revision';
|
||||
prefix: string;
|
||||
period: string;
|
||||
branchId: string;
|
||||
currentNumber: number;
|
||||
paddingLength: number;
|
||||
}
|
||||
|
||||
export interface MasterOption {
|
||||
id: string;
|
||||
group: 'status' | 'product_type' | 'payment_term' | 'currency' | 'tax_rate';
|
||||
code: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export interface TemplateMapping {
|
||||
id: string;
|
||||
key: string;
|
||||
placeholder: string;
|
||||
sourceField: string;
|
||||
}
|
||||
|
||||
export interface QuotationTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
branchId?: string;
|
||||
description: string;
|
||||
mappings: TemplateMapping[];
|
||||
}
|
||||
|
||||
export interface CrmDashboardMetric {
|
||||
id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
change: string;
|
||||
trend: 'up' | 'down' | 'flat';
|
||||
tone?: CrmStatusTone;
|
||||
}
|
||||
|
||||
export interface CrmChartDatum {
|
||||
label: string;
|
||||
value: number;
|
||||
secondaryValue?: number;
|
||||
}
|
||||
|
||||
export interface CrmDashboardData {
|
||||
metrics: CrmDashboardMetric[];
|
||||
enquiryPipeline: CrmChartDatum[];
|
||||
quotationPipeline: CrmChartDatum[];
|
||||
salesPerformance: CrmChartDatum[];
|
||||
competitorAnalysis: CrmChartDatum[];
|
||||
wonLostTrend: CrmChartDatum[];
|
||||
pendingApprovals: ApprovalItem[];
|
||||
hotQuotations: Quotation[];
|
||||
dueFollowUps: QuotationFollowUp[];
|
||||
}
|
||||
|
||||
export interface CustomerFilters {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
search?: string;
|
||||
status?: string;
|
||||
branch?: string;
|
||||
sort?: string;
|
||||
}
|
||||
|
||||
export interface EnquiryFilters {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
search?: string;
|
||||
status?: string;
|
||||
productType?: string;
|
||||
salesman?: string;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
sort?: string;
|
||||
}
|
||||
|
||||
export interface QuotationFilters {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
search?: string;
|
||||
status?: string;
|
||||
quotationType?: string;
|
||||
salesman?: string;
|
||||
hot?: string;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
sort?: string;
|
||||
}
|
||||
|
||||
export interface ApprovalFilters {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
branch?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
items: T[];
|
||||
totalItems: number;
|
||||
summary?: {
|
||||
activeFilters: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CustomerDetailResponse {
|
||||
customer: Customer;
|
||||
contacts: CustomerContact[];
|
||||
shares: ContactShare[];
|
||||
shareLogs: ContactShareLog[];
|
||||
enquiries: Enquiry[];
|
||||
quotations: Quotation[];
|
||||
auditEvents: AuditEvent[];
|
||||
branch: CrmBranch;
|
||||
}
|
||||
|
||||
export interface EnquiryDetailResponse {
|
||||
enquiry: Enquiry;
|
||||
customer: Customer;
|
||||
contact: CustomerContact;
|
||||
quotations: Quotation[];
|
||||
branch: CrmBranch;
|
||||
salesman: CrmSalesperson;
|
||||
}
|
||||
|
||||
export interface QuotationDetailResponse {
|
||||
quotation: Quotation;
|
||||
enquiry: Enquiry;
|
||||
customers: Customer[];
|
||||
contacts: CustomerContact[];
|
||||
branch: CrmBranch;
|
||||
salesman: CrmSalesperson;
|
||||
saleAdmin: CrmSalesperson;
|
||||
auditEvents: AuditEvent[];
|
||||
}
|
||||
|
||||
export interface CrmReferenceData {
|
||||
branches: CrmBranch[];
|
||||
salespersons: CrmSalesperson[];
|
||||
masterOptions: MasterOption[];
|
||||
sequences: DocumentSequence[];
|
||||
templates: QuotationTemplate[];
|
||||
}
|
||||
|
||||
export interface CustomerMutationPayload {
|
||||
id?: string;
|
||||
code: string;
|
||||
name: string;
|
||||
taxId: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
customerType: Customer['customerType'];
|
||||
customerStatus: Customer['customerStatus'];
|
||||
branchId: string;
|
||||
address: string;
|
||||
province: string;
|
||||
district: string;
|
||||
subDistrict: string;
|
||||
postalCode: string;
|
||||
}
|
||||
|
||||
export interface ApprovalActionPayload {
|
||||
quotationId: string;
|
||||
comment?: string;
|
||||
}
|
||||
|
||||
export interface ContactSharePayload {
|
||||
contactId: string;
|
||||
sharedWithUserId: string;
|
||||
sharedWithUserName: string;
|
||||
permission: 'view' | 'edit';
|
||||
}
|
||||
146
src/features/crm/components/approvals-page.tsx
Normal file
146
src/features/crm/components/approvals-page.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { approvalsQueryOptions, crmReferenceQueryOptions } from '../api/queries';
|
||||
import { approveQuotationMutation, rejectQuotationMutation } from '../api/mutations';
|
||||
import { BranchBadge, SectionCard, TimelineList } from './shared';
|
||||
import { formatCurrency } from '../utils/format';
|
||||
|
||||
function DecisionDialog({
|
||||
label,
|
||||
description,
|
||||
isPending,
|
||||
onConfirm
|
||||
}: {
|
||||
label: string;
|
||||
description: string;
|
||||
isPending: boolean;
|
||||
onConfirm: (comment: string) => void;
|
||||
}) {
|
||||
const [comment, setComment] = useState('');
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant='outline' size='sm'>
|
||||
{label}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{label}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Input value={comment} onChange={(event) => setComment(event.target.value)} placeholder='Comment' />
|
||||
<DialogFooter>
|
||||
<Button isLoading={isPending} onClick={() => onConfirm(comment)}>
|
||||
Confirm
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function ApprovalsPage() {
|
||||
const { data } = useSuspenseQuery(approvalsQueryOptions({ page: 1, limit: 10 }));
|
||||
const { data: reference } = useSuspenseQuery(crmReferenceQueryOptions());
|
||||
const baseApproveMutation = approveQuotationMutation;
|
||||
const baseRejectMutation = rejectQuotationMutation;
|
||||
|
||||
const approveMutation = useMutation({
|
||||
...baseApproveMutation,
|
||||
onSuccess: async (...args) => {
|
||||
await baseApproveMutation.onSuccess?.(...args);
|
||||
toast.success('Approve mock สำเร็จ');
|
||||
}
|
||||
});
|
||||
|
||||
const rejectMutation = useMutation({
|
||||
...baseRejectMutation,
|
||||
onSuccess: async (...args) => {
|
||||
await baseRejectMutation.onSuccess?.(...args);
|
||||
toast.success('Reject mock สำเร็จ');
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className='grid gap-4 xl:grid-cols-12'>
|
||||
<SectionCard
|
||||
title='Pending Approval List'
|
||||
description='Sequential approval MVP'
|
||||
className='xl:col-span-7'
|
||||
>
|
||||
<div className='space-y-3'>
|
||||
{data.items.map((item) => {
|
||||
const branch = reference.branches.find((value) => value.id === item.branchId);
|
||||
return (
|
||||
<div key={item.id} className='rounded-xl border bg-muted/10 p-4'>
|
||||
<div className='flex flex-wrap items-start justify-between gap-3'>
|
||||
<div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<p className='font-semibold'>{item.quotationCode}</p>
|
||||
{branch ? <BranchBadge branch={branch} /> : null}
|
||||
</div>
|
||||
<p className='text-muted-foreground mt-1 text-sm'>
|
||||
{item.productType} / Submitter: {item.submitterName}
|
||||
</p>
|
||||
<p className='mt-2 text-sm font-medium'>
|
||||
Level {item.approverLevel} / {formatCurrency(item.amount)}
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex gap-2'>
|
||||
<DecisionDialog
|
||||
label='Approve'
|
||||
description='อนุมัติ quotation mock นี้'
|
||||
isPending={approveMutation.isPending}
|
||||
onConfirm={(comment) =>
|
||||
approveMutation.mutate({ quotationId: item.quotationId, comment })
|
||||
}
|
||||
/>
|
||||
<DecisionDialog
|
||||
label='Reject'
|
||||
description='Reject quotation mock นี้'
|
||||
isPending={rejectMutation.isPending}
|
||||
onConfirm={(comment) =>
|
||||
rejectMutation.mutate({ quotationId: item.quotationId, comment })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
title='Approval Timeline'
|
||||
description='ภาพรวมเส้นทางการอนุมัติ'
|
||||
className='xl:col-span-5'
|
||||
>
|
||||
<TimelineList
|
||||
items={data.items.map((item) => ({
|
||||
id: item.id,
|
||||
title: `${item.quotationCode} / Level ${item.approverLevel}`,
|
||||
detail: `${item.submitterName} ส่งอนุมัติสำหรับ ${item.productType}`,
|
||||
timestamp: '2026-06-11'
|
||||
}))}
|
||||
/>
|
||||
</SectionCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
172
src/features/crm/components/crm-dashboard.tsx
Normal file
172
src/features/crm/components/crm-dashboard.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
'use client';
|
||||
|
||||
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { Area, AreaChart, Bar, BarChart, CartesianGrid, Cell, Pie, PieChart, XAxis, YAxis } from 'recharts';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart';
|
||||
import { crmDashboardQueryOptions } from '../api/queries';
|
||||
import { KpiCard, QuotationStatusBadge, RelatedQuotationList, SectionCard } from './shared';
|
||||
import { formatCurrency, formatDate } from '../utils/format';
|
||||
|
||||
const chartConfig = {
|
||||
primary: { label: 'Primary', color: 'var(--chart-1)' },
|
||||
secondary: { label: 'Secondary', color: 'var(--chart-2)' },
|
||||
tertiary: { label: 'Tertiary', color: 'var(--chart-3)' }
|
||||
};
|
||||
|
||||
export function CrmDashboardPage() {
|
||||
const { data } = useSuspenseQuery(crmDashboardQueryOptions());
|
||||
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
<div className='grid gap-4 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-5'>
|
||||
{data.metrics.map((metric) => (
|
||||
<KpiCard
|
||||
key={metric.id}
|
||||
label={metric.label}
|
||||
value={metric.value}
|
||||
change={metric.change}
|
||||
trend={metric.trend}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className='grid gap-4 xl:grid-cols-12'>
|
||||
<Card className='xl:col-span-5'>
|
||||
<CardHeader>
|
||||
<CardTitle>Enquiry Pipeline</CardTitle>
|
||||
<CardDescription>สถานะ opportunity ปัจจุบัน</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer config={chartConfig}>
|
||||
<BarChart data={data.enquiryPipeline}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis dataKey='label' tickLine={false} axisLine={false} />
|
||||
<YAxis allowDecimals={false} />
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
<Bar dataKey='value' radius={8} fill='var(--chart-1)' />
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className='xl:col-span-4'>
|
||||
<CardHeader>
|
||||
<CardTitle>Quotation Pipeline</CardTitle>
|
||||
<CardDescription>ภาพรวมเอกสารเสนอราคา</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer config={chartConfig}>
|
||||
<PieChart>
|
||||
<Pie data={data.quotationPipeline} dataKey='value' nameKey='label' innerRadius={56} outerRadius={92}>
|
||||
{data.quotationPipeline.map((entry, index) => (
|
||||
<Cell
|
||||
key={entry.label}
|
||||
fill={['var(--chart-1)', 'var(--chart-2)', 'var(--chart-3)', 'var(--chart-4)', 'var(--chart-5)'][index % 5]}
|
||||
/>
|
||||
))}
|
||||
</Pie>
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className='xl:col-span-3'>
|
||||
<CardHeader>
|
||||
<CardTitle>Competitor Analysis</CardTitle>
|
||||
<CardDescription>คู่แข่งที่เจอบ่อยใน pipeline</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-3'>
|
||||
{data.competitorAnalysis.map((item) => (
|
||||
<div key={item.label} className='space-y-1'>
|
||||
<div className='flex items-center justify-between text-sm'>
|
||||
<span>{item.label}</span>
|
||||
<span className='font-medium'>{item.value}</span>
|
||||
</div>
|
||||
<div className='bg-muted h-2 rounded-full'>
|
||||
<div
|
||||
className='bg-primary h-2 rounded-full'
|
||||
style={{ width: `${(item.value / 4) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className='grid gap-4 xl:grid-cols-12'>
|
||||
<Card className='xl:col-span-6'>
|
||||
<CardHeader>
|
||||
<CardTitle>Sales Performance</CardTitle>
|
||||
<CardDescription>จำนวน quotation และมูลค่าแยกตาม salesperson</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer config={chartConfig}>
|
||||
<BarChart data={data.salesPerformance}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis dataKey='label' tickLine={false} axisLine={false} />
|
||||
<YAxis allowDecimals={false} />
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
<Bar dataKey='value' fill='var(--chart-2)' radius={8} />
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className='xl:col-span-6'>
|
||||
<CardHeader>
|
||||
<CardTitle>Won / Lost Trend</CardTitle>
|
||||
<CardDescription>เทียบจำนวนดีลชนะและแพ้รายเดือน</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer config={chartConfig}>
|
||||
<AreaChart data={data.wonLostTrend}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis dataKey='label' tickLine={false} axisLine={false} />
|
||||
<YAxis allowDecimals={false} />
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
<Area type='monotone' dataKey='value' stroke='var(--chart-1)' fill='var(--chart-1)' fillOpacity={0.2} />
|
||||
<Area type='monotone' dataKey='secondaryValue' stroke='var(--chart-3)' fill='var(--chart-3)' fillOpacity={0.12} />
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className='grid gap-4 xl:grid-cols-12'>
|
||||
<SectionCard title='Pending Approvals' description='รายการที่ต้องตัดสินใจวันนี้' className='xl:col-span-4'>
|
||||
<div className='space-y-3'>
|
||||
{data.pendingApprovals.map((item) => (
|
||||
<div key={item.id} className='rounded-lg border p-3'>
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<p className='font-medium'>{item.quotationCode}</p>
|
||||
<QuotationStatusBadge status='pending_approval' />
|
||||
</div>
|
||||
<p className='text-muted-foreground mt-1 text-sm'>{item.productType} / Approver Level {item.approverLevel}</p>
|
||||
<p className='mt-2 text-sm font-medium'>{formatCurrency(item.amount)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard title='Hot Projects' description='ดีลที่ต้องติดตามใกล้ชิด' className='xl:col-span-5'>
|
||||
<RelatedQuotationList quotations={data.hotQuotations} />
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard title='Follow-ups Due Today' description='กิจกรรมติดตามที่ครบกำหนด' className='xl:col-span-3'>
|
||||
<div className='space-y-3'>
|
||||
{data.dueFollowUps.map((item) => (
|
||||
<div key={item.id} className='rounded-lg border p-3'>
|
||||
<p className='font-medium'>{item.title}</p>
|
||||
<p className='text-muted-foreground mt-1 text-sm'>Owner: {item.ownerName}</p>
|
||||
<p className='text-muted-foreground text-sm'>Due: {formatDate(item.dueDate)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SectionCard>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
208
src/features/crm/components/customer-detail.tsx
Normal file
208
src/features/crm/components/customer-detail.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { customerByIdOptions } from '../api/queries';
|
||||
import { revokeContactShareMutation, shareContactMutation } from '../api/mutations';
|
||||
import {
|
||||
AuditTimeline,
|
||||
EmptyState,
|
||||
InfoGrid,
|
||||
RelatedQuotationList,
|
||||
SectionCard,
|
||||
TimelineList
|
||||
} from './shared';
|
||||
import { formatDate } from '../utils/format';
|
||||
|
||||
export function CustomerDetailPage({ id }: { id: string }) {
|
||||
const { data } = useSuspenseQuery(customerByIdOptions(id));
|
||||
const [targetUserName, setTargetUserName] = useState('');
|
||||
const baseShareMutation = shareContactMutation;
|
||||
const baseRevokeMutation = revokeContactShareMutation;
|
||||
|
||||
const shareMutation = useMutation({
|
||||
...baseShareMutation,
|
||||
onSuccess: async (...args) => {
|
||||
await baseShareMutation.onSuccess?.(...args);
|
||||
toast.success('แชร์ contact mock สำเร็จ');
|
||||
setTargetUserName('');
|
||||
}
|
||||
});
|
||||
|
||||
const revokeMutation = useMutation({
|
||||
...baseRevokeMutation,
|
||||
onSuccess: async (...args) => {
|
||||
await baseRevokeMutation.onSuccess?.(...args);
|
||||
toast.success('ยกเลิกแชร์ contact mock สำเร็จ');
|
||||
}
|
||||
});
|
||||
|
||||
const primaryContact = data.contacts.find((contact) => contact.isPrimary) ?? data.contacts[0];
|
||||
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
<InfoGrid
|
||||
cols={4}
|
||||
items={[
|
||||
{ label: 'Customer Code', value: data.customer.code },
|
||||
{ label: 'Primary Contact', value: primaryContact?.name ?? '-' },
|
||||
{ label: 'Branch', value: data.branch.name },
|
||||
{
|
||||
label: 'Last Activity',
|
||||
value: data.auditEvents[0] ? formatDate(data.auditEvents[0].createdAt) : '-'
|
||||
}
|
||||
]}
|
||||
/>
|
||||
|
||||
<Tabs defaultValue='overview' className='space-y-4'>
|
||||
<TabsList>
|
||||
<TabsTrigger value='overview'>Overview</TabsTrigger>
|
||||
<TabsTrigger value='contacts'>Contacts</TabsTrigger>
|
||||
<TabsTrigger value='shared'>Shared Contacts</TabsTrigger>
|
||||
<TabsTrigger value='enquiries'>Enquiries</TabsTrigger>
|
||||
<TabsTrigger value='quotations'>Quotations</TabsTrigger>
|
||||
<TabsTrigger value='activity'>Activity Log</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value='overview'>
|
||||
<SectionCard title='Customer Overview'>
|
||||
<InfoGrid
|
||||
cols={3}
|
||||
items={[
|
||||
{ label: 'Tax ID', value: data.customer.taxId },
|
||||
{ label: 'Email', value: data.customer.email },
|
||||
{ label: 'Phone', value: data.customer.phone },
|
||||
{
|
||||
label: 'Address',
|
||||
value: `${data.customer.address}, ${data.customer.subDistrict}`
|
||||
},
|
||||
{ label: 'District', value: data.customer.district },
|
||||
{ label: 'Province', value: data.customer.province }
|
||||
]}
|
||||
/>
|
||||
</SectionCard>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='contacts'>
|
||||
<SectionCard title='Customer Contacts'>
|
||||
<div className='grid gap-3 md:grid-cols-2'>
|
||||
{data.contacts.map((contact) => (
|
||||
<div key={contact.id} className='rounded-lg border p-4'>
|
||||
<div className='flex items-center justify-between gap-2'>
|
||||
<p className='font-medium'>{contact.name}</p>
|
||||
{contact.isPrimary ? (
|
||||
<span className='text-primary text-xs'>Primary</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className='text-muted-foreground text-sm'>{contact.position}</p>
|
||||
<p className='mt-2 text-sm'>{contact.email}</p>
|
||||
<p className='text-sm'>{contact.phone}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SectionCard>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='shared'>
|
||||
<SectionCard title='Share Contact' description='Mock contact sharing UI'>
|
||||
<div className='flex flex-col gap-3 md:flex-row'>
|
||||
<Input
|
||||
placeholder='ชื่อผู้ใช้ที่ต้องการแชร์'
|
||||
value={targetUserName}
|
||||
onChange={(event) => setTargetUserName(event.target.value)}
|
||||
/>
|
||||
<Button
|
||||
isLoading={shareMutation.isPending}
|
||||
onClick={() =>
|
||||
primaryContact &&
|
||||
targetUserName &&
|
||||
shareMutation.mutate({
|
||||
contactId: primaryContact.id,
|
||||
sharedWithUserId: `mock-${targetUserName}`,
|
||||
sharedWithUserName: targetUserName,
|
||||
permission: 'view'
|
||||
})
|
||||
}
|
||||
>
|
||||
แชร์ Contact
|
||||
</Button>
|
||||
</div>
|
||||
<div className='mt-4 space-y-3'>
|
||||
{data.shares.length === 0 ? (
|
||||
<EmptyState
|
||||
title='ยังไม่มีการแชร์ contact'
|
||||
description='เริ่มจากกรอกชื่อผู้ใช้ด้านบน'
|
||||
/>
|
||||
) : (
|
||||
data.shares.map((share) => (
|
||||
<div
|
||||
key={share.id}
|
||||
className='flex flex-wrap items-center justify-between gap-3 rounded-lg border p-3'
|
||||
>
|
||||
<div>
|
||||
<p className='font-medium'>{share.sharedWithUserName}</p>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
Permission: {share.permission}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
isLoading={revokeMutation.isPending}
|
||||
onClick={() =>
|
||||
revokeMutation.mutate({
|
||||
contactId: share.contactId,
|
||||
shareId: share.id
|
||||
})
|
||||
}
|
||||
>
|
||||
Revoke
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className='mt-6'>
|
||||
<TimelineList
|
||||
items={data.shareLogs.map((log) => ({
|
||||
id: log.id,
|
||||
title: log.action,
|
||||
detail: `${log.targetUserName} โดย ${log.actorName}`,
|
||||
timestamp: log.createdAt,
|
||||
tone: log.action === 'SHARE' ? 'success' : 'danger'
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
</SectionCard>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='enquiries'>
|
||||
<SectionCard title='Customer Enquiries'>
|
||||
<div className='space-y-3'>
|
||||
{data.enquiries.map((enquiry) => (
|
||||
<div key={enquiry.id} className='rounded-lg border p-3'>
|
||||
<p className='font-medium'>{enquiry.code}</p>
|
||||
<p className='text-muted-foreground text-sm'>{enquiry.title}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SectionCard>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='quotations'>
|
||||
<RelatedQuotationList quotations={data.quotations} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='activity'>
|
||||
<SectionCard title='Activity Log'>
|
||||
<AuditTimeline events={data.auditEvents} />
|
||||
</SectionCard>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
209
src/features/crm/components/customer-form-sheet.tsx
Normal file
209
src/features/crm/components/customer-form-sheet.tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useMutation, useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle
|
||||
} from '@/components/ui/sheet';
|
||||
import { useAppForm, useFormFields } from '@/components/ui/tanstack-form';
|
||||
import { crmReferenceQueryOptions } from '../api/queries';
|
||||
import { createCustomerMutation, updateCustomerMutation } from '../api/mutations';
|
||||
import type { Customer } from '../api/types';
|
||||
import { customerStatusOptions } from '../data/options';
|
||||
import { customerSchema, type CustomerFormValues } from '../schemas/customer';
|
||||
|
||||
const customerTypeOptions = [
|
||||
{ value: 'developer', label: 'Developer' },
|
||||
{ value: 'contractor', label: 'Contractor' },
|
||||
{ value: 'owner', label: 'Owner' },
|
||||
{ value: 'consultant', label: 'Consultant' }
|
||||
];
|
||||
|
||||
export function CustomerFormSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
customer
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
customer?: Customer;
|
||||
}) {
|
||||
const { data: reference } = useSuspenseQuery(crmReferenceQueryOptions());
|
||||
const { FormTextField, FormSelectField } = useFormFields<CustomerFormValues>();
|
||||
const isEdit = !!customer;
|
||||
const baseCreateMutation = createCustomerMutation;
|
||||
const baseUpdateMutation = updateCustomerMutation;
|
||||
|
||||
const createMutation = useMutation({
|
||||
...baseCreateMutation,
|
||||
onSuccess: async (...args) => {
|
||||
await baseCreateMutation.onSuccess?.(...args);
|
||||
toast.success('สร้าง customer mock สำเร็จ');
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (error) => toast.error(error instanceof Error ? error.message : 'บันทึกไม่สำเร็จ')
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
...baseUpdateMutation,
|
||||
onSuccess: async (...args) => {
|
||||
await baseUpdateMutation.onSuccess?.(...args);
|
||||
toast.success('อัปเดต customer mock สำเร็จ');
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (error) => toast.error(error instanceof Error ? error.message : 'บันทึกไม่สำเร็จ')
|
||||
});
|
||||
|
||||
const form = useAppForm({
|
||||
defaultValues: {
|
||||
code: customer?.code ?? '',
|
||||
name: customer?.name ?? '',
|
||||
taxId: customer?.taxId ?? '',
|
||||
email: customer?.email ?? '',
|
||||
phone: customer?.phone ?? '',
|
||||
customerType: customer?.customerType ?? 'developer',
|
||||
customerStatus: customer?.customerStatus ?? 'active',
|
||||
branchId: customer?.branchId ?? reference.branches[0]?.id ?? '',
|
||||
address: customer?.address ?? '',
|
||||
province: customer?.province ?? '',
|
||||
district: customer?.district ?? '',
|
||||
subDistrict: customer?.subDistrict ?? '',
|
||||
postalCode: customer?.postalCode ?? ''
|
||||
} as CustomerFormValues,
|
||||
validators: {
|
||||
onSubmit: customerSchema
|
||||
},
|
||||
onSubmit: async ({ value }) => {
|
||||
if (isEdit && customer) {
|
||||
await updateMutation.mutateAsync({ id: customer.id, values: value });
|
||||
return;
|
||||
}
|
||||
await createMutation.mutateAsync(value);
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
form.reset({
|
||||
code: customer?.code ?? '',
|
||||
name: customer?.name ?? '',
|
||||
taxId: customer?.taxId ?? '',
|
||||
email: customer?.email ?? '',
|
||||
phone: customer?.phone ?? '',
|
||||
customerType: customer?.customerType ?? 'developer',
|
||||
customerStatus: customer?.customerStatus ?? 'active',
|
||||
branchId: customer?.branchId ?? reference.branches[0]?.id ?? '',
|
||||
address: customer?.address ?? '',
|
||||
province: customer?.province ?? '',
|
||||
district: customer?.district ?? '',
|
||||
subDistrict: customer?.subDistrict ?? '',
|
||||
postalCode: customer?.postalCode ?? ''
|
||||
});
|
||||
}, [customer, form, open, reference.branches]);
|
||||
|
||||
const isPending = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className='sm:max-w-2xl'>
|
||||
<SheetHeader>
|
||||
<SheetTitle>{isEdit ? 'แก้ไข Customer' : 'สร้าง Customer'}</SheetTitle>
|
||||
<SheetDescription>
|
||||
ใช้ mock form ตาม pattern เดิมของ repo และพร้อมสลับไป service จริงภายหลัง
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className='max-h-[calc(100vh-10rem)] overflow-auto pr-2'>
|
||||
<form.AppForm>
|
||||
<form.Form id='customer-form' className='grid gap-2 md:grid-cols-2'>
|
||||
<FormTextField name='code' label='Customer Code' required placeholder='CUS-BKK-006' />
|
||||
<FormTextField
|
||||
name='name'
|
||||
label='Customer Name'
|
||||
required
|
||||
placeholder='ALLA Distribution'
|
||||
/>
|
||||
<FormTextField name='taxId' label='Tax ID' required placeholder='0105556...' />
|
||||
<FormTextField
|
||||
name='email'
|
||||
label='Email'
|
||||
required
|
||||
type='email'
|
||||
placeholder='contact@example.com'
|
||||
/>
|
||||
<FormTextField name='phone' label='Phone' required placeholder='02-000-0000' />
|
||||
<FormSelectField
|
||||
name='customerType'
|
||||
label='Customer Type'
|
||||
required
|
||||
options={customerTypeOptions}
|
||||
/>
|
||||
<FormSelectField
|
||||
name='customerStatus'
|
||||
label='Status'
|
||||
required
|
||||
options={customerStatusOptions.map((item) => ({
|
||||
value: item.value,
|
||||
label: item.label
|
||||
}))}
|
||||
/>
|
||||
<FormSelectField
|
||||
name='branchId'
|
||||
label='Branch'
|
||||
required
|
||||
options={reference.branches.map((branch) => ({
|
||||
value: branch.id,
|
||||
label: branch.name
|
||||
}))}
|
||||
/>
|
||||
<div className='md:col-span-2'>
|
||||
<FormTextField
|
||||
name='address'
|
||||
label='Address'
|
||||
required
|
||||
placeholder='123 Main Road'
|
||||
/>
|
||||
</div>
|
||||
<FormTextField name='province' label='Province' required placeholder='Bangkok' />
|
||||
<FormTextField
|
||||
name='district'
|
||||
label='District'
|
||||
required
|
||||
placeholder='Chatuchak'
|
||||
/>
|
||||
<FormTextField
|
||||
name='subDistrict'
|
||||
label='Sub District'
|
||||
required
|
||||
placeholder='Chom Phon'
|
||||
/>
|
||||
<FormTextField
|
||||
name='postalCode'
|
||||
label='Postal Code'
|
||||
required
|
||||
placeholder='10900'
|
||||
/>
|
||||
</form.Form>
|
||||
</form.AppForm>
|
||||
</div>
|
||||
|
||||
<SheetFooter>
|
||||
<Button type='button' variant='outline' onClick={() => onOpenChange(false)}>
|
||||
ยกเลิก
|
||||
</Button>
|
||||
<Button type='submit' form='customer-form' isLoading={isPending}>
|
||||
{isEdit ? 'บันทึกการแก้ไข' : 'สร้าง Customer'}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
207
src/features/crm/components/customers-table.tsx
Normal file
207
src/features/crm/components/customers-table.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { type ColumnDef } from "@tanstack/react-table";
|
||||
import { parseAsInteger, parseAsString, useQueryStates } from "nuqs";
|
||||
import { getSortingStateParser } from "@/lib/parsers";
|
||||
import { useDataTable } from "@/hooks/use-data-table";
|
||||
import { DataTable } from "@/components/ui/table/data-table";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Icons } from "@/components/icons";
|
||||
import {
|
||||
customersQueryOptions,
|
||||
crmReferenceQueryOptions,
|
||||
} from "../api/queries";
|
||||
import type { Customer } from "../api/types";
|
||||
import { CustomerFormSheet } from "./customer-form-sheet";
|
||||
import { BranchBadge, CustomerStatusBadge } from "./shared";
|
||||
|
||||
export function CustomersTablePage() {
|
||||
const [editingCustomer, setEditingCustomer] = useState<
|
||||
Customer | undefined
|
||||
>();
|
||||
const [sheetOpen, setSheetOpen] = useState(false);
|
||||
|
||||
const columns = useMemo<ColumnDef<Customer>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: "code",
|
||||
header: "Code",
|
||||
cell: ({ row }) => (
|
||||
<span className="font-medium">{row.original.code}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: "Customer",
|
||||
cell: ({ row }) => (
|
||||
<div>
|
||||
<p className="font-medium">{row.original.name}</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{row.original.email}
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "customerStatus",
|
||||
header: "Status",
|
||||
cell: ({ row }) => (
|
||||
<CustomerStatusBadge status={row.original.customerStatus} />
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "branchId",
|
||||
header: "Branch",
|
||||
cell: ({ row, table }) => {
|
||||
const branches = (
|
||||
table.options.meta as {
|
||||
branches: Array<{
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
region: string;
|
||||
}>;
|
||||
}
|
||||
).branches;
|
||||
const branch = branches.find(
|
||||
(item) => item.id === row.original.branchId,
|
||||
);
|
||||
return branch ? <BranchBadge branch={branch} /> : null;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "contactIds",
|
||||
header: "Contacts",
|
||||
cell: ({ row }) => row.original.contactIds.length,
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "",
|
||||
cell: ({ row }) => (
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setEditingCustomer(row.original);
|
||||
setSheetOpen(true);
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href={`/dashboard/crm/customers/${row.original.id}`}>
|
||||
View
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
const columnIds = columns
|
||||
.map((column) => column.id ?? String(column.accessorKey))
|
||||
.filter(Boolean);
|
||||
|
||||
const [params, setParams] = useQueryStates({
|
||||
page: parseAsInteger.withDefault(1),
|
||||
perPage: parseAsInteger.withDefault(10),
|
||||
name: parseAsString,
|
||||
customerStatus: parseAsString,
|
||||
branch: parseAsString,
|
||||
sort: getSortingStateParser(columnIds).withDefault([]),
|
||||
});
|
||||
const { data: reference } = useSuspenseQuery(crmReferenceQueryOptions());
|
||||
|
||||
const filters = {
|
||||
page: params.page,
|
||||
limit: params.perPage,
|
||||
...(params.name && { search: params.name }),
|
||||
...(params.customerStatus && { status: params.customerStatus }),
|
||||
...(params.branch && { branch: params.branch }),
|
||||
...(params.sort.length ? { sort: JSON.stringify(params.sort) } : {}),
|
||||
};
|
||||
|
||||
const { data } = useSuspenseQuery(customersQueryOptions(filters));
|
||||
const pageCount = Math.ceil(data.totalItems / params.perPage);
|
||||
|
||||
const { table } = useDataTable({
|
||||
data: data.items,
|
||||
columns,
|
||||
pageCount,
|
||||
meta: { branches: reference.branches },
|
||||
initialState: {
|
||||
columnPinning: { right: ["actions"] },
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col space-y-4">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="grid gap-3 md:grid-cols-3 lg:w-full">
|
||||
<Input
|
||||
placeholder="ค้นหา customer หรือ code"
|
||||
value={params.name ?? ""}
|
||||
onChange={(event) =>
|
||||
void setParams({ name: event.target.value || null, page: 1 })
|
||||
}
|
||||
/>
|
||||
<select
|
||||
className="border-input bg-background rounded-md border px-3 text-sm"
|
||||
value={params.customerStatus ?? ""}
|
||||
onChange={(event) =>
|
||||
void setParams({
|
||||
customerStatus: event.target.value || null,
|
||||
page: 1,
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="">ทุกสถานะ</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="prospect">Prospect</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
</select>
|
||||
<select
|
||||
className="border-input bg-background rounded-md border px-3 text-sm"
|
||||
value={params.branch ?? ""}
|
||||
onChange={(event) =>
|
||||
void setParams({ branch: event.target.value || null, page: 1 })
|
||||
}
|
||||
>
|
||||
<option value="">ทุกสาขา</option>
|
||||
{reference.branches.map((branch) => (
|
||||
<option key={branch.id} value={branch.id}>
|
||||
{branch.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setEditingCustomer(undefined);
|
||||
setSheetOpen(true);
|
||||
}}
|
||||
>
|
||||
<Icons.add className="mr-2 h-4 w-4" />
|
||||
Create Customer
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DataTable table={table} />
|
||||
<CustomerFormSheet
|
||||
open={sheetOpen}
|
||||
onOpenChange={(open) => {
|
||||
setSheetOpen(open);
|
||||
if (!open) setEditingCustomer(undefined);
|
||||
}}
|
||||
customer={editingCustomer}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
178
src/features/crm/components/enquiries-table.tsx
Normal file
178
src/features/crm/components/enquiries-table.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useMutation, useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { type ColumnDef } from "@tanstack/react-table";
|
||||
import { parseAsInteger, parseAsString, useQueryStates } from "nuqs";
|
||||
import { getSortingStateParser } from "@/lib/parsers";
|
||||
import { useDataTable } from "@/hooks/use-data-table";
|
||||
import { DataTable } from "@/components/ui/table/data-table";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
crmReferenceQueryOptions,
|
||||
enquiriesQueryOptions,
|
||||
} from "../api/queries";
|
||||
import { convertEnquiryMutation } from "../api/mutations";
|
||||
import type { Enquiry } from "../api/types";
|
||||
import { ChancePill, EnquiryStatusBadge } from "./shared";
|
||||
|
||||
const columns: ColumnDef<Enquiry>[] = [
|
||||
{ accessorKey: "code", header: "Code" },
|
||||
{ accessorKey: "title", header: "Enquiry" },
|
||||
{ accessorKey: "productType", header: "Product" },
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: "Status",
|
||||
cell: ({ row }) => <EnquiryStatusBadge status={row.original.status} />,
|
||||
},
|
||||
{
|
||||
accessorKey: "chancePercent",
|
||||
header: "Chance",
|
||||
cell: ({ row }) => <ChancePill value={row.original.chancePercent} />,
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "",
|
||||
cell: ({ row }) => <RowActions enquiry={row.original} />,
|
||||
},
|
||||
];
|
||||
|
||||
const columnIds = columns
|
||||
.map((column) => column.id ?? String(column.accessorKey))
|
||||
.filter(Boolean);
|
||||
|
||||
function RowActions({ enquiry }: { enquiry: Enquiry }) {
|
||||
const mutation = useMutation({
|
||||
...convertEnquiryMutation,
|
||||
onSuccess: () => toast.success("สร้าง quotation mock จาก enquiry แล้ว"),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href={`/dashboard/crm/enquiries/${enquiry.id}`}>View</Link>
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
isLoading={mutation.isPending}
|
||||
onClick={() => mutation.mutate(enquiry.id)}
|
||||
>
|
||||
Convert
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EnquiriesTablePage() {
|
||||
const [params, setParams] = useQueryStates({
|
||||
page: parseAsInteger.withDefault(1),
|
||||
perPage: parseAsInteger.withDefault(10),
|
||||
name: parseAsString,
|
||||
status: parseAsString,
|
||||
productType: parseAsString,
|
||||
salesman: parseAsString,
|
||||
dateFrom: parseAsString,
|
||||
dateTo: parseAsString,
|
||||
sort: getSortingStateParser(columnIds).withDefault([]),
|
||||
});
|
||||
const { data: reference } = useSuspenseQuery(crmReferenceQueryOptions());
|
||||
|
||||
const filters = {
|
||||
page: params.page,
|
||||
limit: params.perPage,
|
||||
...(params.name && { search: params.name }),
|
||||
...(params.status && { status: params.status }),
|
||||
...(params.productType && { productType: params.productType }),
|
||||
...(params.salesman && { salesman: params.salesman }),
|
||||
...(params.dateFrom && { dateFrom: params.dateFrom }),
|
||||
...(params.dateTo && { dateTo: params.dateTo }),
|
||||
...(params.sort.length ? { sort: JSON.stringify(params.sort) } : {}),
|
||||
};
|
||||
|
||||
const { data } = useSuspenseQuery(enquiriesQueryOptions(filters));
|
||||
const pageCount = Math.ceil(data.totalItems / params.perPage);
|
||||
const { table } = useDataTable({
|
||||
data: data.items,
|
||||
columns,
|
||||
pageCount,
|
||||
initialState: {
|
||||
columnPinning: { right: ["actions"] },
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col space-y-4">
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-6">
|
||||
<Input
|
||||
placeholder="ค้นหา enquiry หรือ code"
|
||||
value={params.name ?? ""}
|
||||
onChange={(event) =>
|
||||
void setParams({ name: event.target.value || null, page: 1 })
|
||||
}
|
||||
/>
|
||||
<select
|
||||
className="border-input bg-background rounded-md border px-3 text-sm"
|
||||
value={params.status ?? ""}
|
||||
onChange={(event) =>
|
||||
void setParams({ status: event.target.value || null, page: 1 })
|
||||
}
|
||||
>
|
||||
<option value="">ทุกสถานะ</option>
|
||||
<option value="new">New</option>
|
||||
<option value="qualifying">Qualifying</option>
|
||||
<option value="requirement">Requirement</option>
|
||||
<option value="follow_up">Follow Up</option>
|
||||
<option value="converted">Converted</option>
|
||||
</select>
|
||||
<select
|
||||
className="border-input bg-background rounded-md border px-3 text-sm"
|
||||
value={params.productType ?? ""}
|
||||
onChange={(event) =>
|
||||
void setParams({ productType: event.target.value || null, page: 1 })
|
||||
}
|
||||
>
|
||||
<option value="">ทุกสินค้า</option>
|
||||
<option value="crane">Crane</option>
|
||||
<option value="dockdoor">Dock Door</option>
|
||||
<option value="solarcell">Solar Cell</option>
|
||||
</select>
|
||||
<select
|
||||
className="border-input bg-background rounded-md border px-3 text-sm"
|
||||
value={params.salesman ?? ""}
|
||||
onChange={(event) =>
|
||||
void setParams({ salesman: event.target.value || null, page: 1 })
|
||||
}
|
||||
>
|
||||
<option value="">ทุก salesperson</option>
|
||||
{reference.salespersons.map((sales) => (
|
||||
<option key={sales.id} value={sales.id}>
|
||||
{sales.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Input
|
||||
type="date"
|
||||
value={params.dateFrom ?? ""}
|
||||
onChange={(event) =>
|
||||
void setParams({ dateFrom: event.target.value || null, page: 1 })
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
type="date"
|
||||
value={params.dateTo ?? ""}
|
||||
onChange={(event) =>
|
||||
void setParams({ dateTo: event.target.value || null, page: 1 })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/dashboard/crm/customers">Create Enquiry</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<DataTable table={table} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
65
src/features/crm/components/enquiry-detail.tsx
Normal file
65
src/features/crm/components/enquiry-detail.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
'use client';
|
||||
|
||||
import { useMutation, useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { enquiryByIdOptions } from '../api/queries';
|
||||
import { convertEnquiryMutation } from '../api/mutations';
|
||||
import { InfoGrid, RelatedQuotationList, SectionCard, TimelineList } from './shared';
|
||||
import { formatCurrency } from '../utils/format';
|
||||
|
||||
export function EnquiryDetailPage({ id }: { id: string }) {
|
||||
const { data } = useSuspenseQuery(enquiryByIdOptions(id));
|
||||
const baseMutation = convertEnquiryMutation;
|
||||
const mutation = useMutation({
|
||||
...baseMutation,
|
||||
onSuccess: async (...args) => {
|
||||
await baseMutation.onSuccess?.(...args);
|
||||
toast.success('สร้าง quotation mock เรียบร้อย');
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
<SectionCard
|
||||
title={data.enquiry.title}
|
||||
description={`${data.enquiry.code} / ${data.branch.name}`}
|
||||
action={
|
||||
<Button isLoading={mutation.isPending} onClick={() => mutation.mutate(data.enquiry.id)}>
|
||||
Convert to Quotation
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<InfoGrid
|
||||
cols={4}
|
||||
items={[
|
||||
{ label: 'Customer', value: data.customer.name },
|
||||
{ label: 'Contact', value: data.contact.name },
|
||||
{ label: 'Chance', value: `${data.enquiry.chancePercent}%` },
|
||||
{ label: 'Potential', value: formatCurrency(data.enquiry.expectedValue) },
|
||||
{ label: 'Project Location', value: data.enquiry.projectLocation },
|
||||
{ label: 'Competitor', value: data.enquiry.competitor },
|
||||
{ label: 'Salesman', value: data.salesman.name },
|
||||
{ label: 'Requirement', value: data.enquiry.requirementSummary }
|
||||
]}
|
||||
/>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard title='Timeline / Activity'>
|
||||
<TimelineList
|
||||
items={data.enquiry.activities.map((activity) => ({
|
||||
id: activity.id,
|
||||
title: activity.title,
|
||||
detail: activity.detail,
|
||||
actorName: activity.actorName,
|
||||
timestamp: activity.createdAt
|
||||
}))}
|
||||
/>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard title='Related Quotations'>
|
||||
<RelatedQuotationList quotations={data.quotations} />
|
||||
</SectionCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
347
src/features/crm/components/quotation-detail.tsx
Normal file
347
src/features/crm/components/quotation-detail.tsx
Normal file
@@ -0,0 +1,347 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger
|
||||
} from '@/components/ui/dialog';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { quotationByIdOptions } from '../api/queries';
|
||||
import {
|
||||
approveQuotationMutation,
|
||||
createQuotationRevisionMutation,
|
||||
markQuotationStatusMutation,
|
||||
rejectQuotationMutation,
|
||||
submitApprovalMutation
|
||||
} from '../api/mutations';
|
||||
import {
|
||||
AuditTimeline,
|
||||
CustomerRoleCards,
|
||||
InfoGrid,
|
||||
QuotationPreviewPanel,
|
||||
QuotationStatusBadge,
|
||||
SectionCard,
|
||||
TimelineList
|
||||
} from './shared';
|
||||
import { formatCurrency } from '../utils/format';
|
||||
|
||||
function ApprovalDialog({
|
||||
title,
|
||||
description,
|
||||
actionLabel,
|
||||
onConfirm,
|
||||
isPending
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
actionLabel: string;
|
||||
onConfirm: (comment: string) => void;
|
||||
isPending: boolean;
|
||||
}) {
|
||||
const [comment, setComment] = useState('');
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant='outline'>{actionLabel}</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Textarea
|
||||
value={comment}
|
||||
onChange={(event) => setComment(event.target.value)}
|
||||
placeholder='ใส่หมายเหตุ (optional)'
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button isLoading={isPending} onClick={() => onConfirm(comment)}>
|
||||
ยืนยัน
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function QuotationDetailPage({ id }: { id: string }) {
|
||||
const { data } = useSuspenseQuery(quotationByIdOptions(id));
|
||||
const baseSubmitApproval = submitApprovalMutation;
|
||||
const baseApproveMutation = approveQuotationMutation;
|
||||
const baseRejectMutation = rejectQuotationMutation;
|
||||
const baseReviseMutation = createQuotationRevisionMutation;
|
||||
const baseMarkStatusMutation = markQuotationStatusMutation;
|
||||
|
||||
const submitApproval = useMutation({
|
||||
...baseSubmitApproval,
|
||||
onSuccess: async (...args) => {
|
||||
await baseSubmitApproval.onSuccess?.(...args);
|
||||
toast.success('ส่งเข้าสายอนุมัติแล้ว');
|
||||
}
|
||||
});
|
||||
const approveMutation = useMutation({
|
||||
...baseApproveMutation,
|
||||
onSuccess: async (...args) => {
|
||||
await baseApproveMutation.onSuccess?.(...args);
|
||||
toast.success('อนุมัติ mock สำเร็จ');
|
||||
}
|
||||
});
|
||||
const rejectMutation = useMutation({
|
||||
...baseRejectMutation,
|
||||
onSuccess: async (...args) => {
|
||||
await baseRejectMutation.onSuccess?.(...args);
|
||||
toast.success('Reject mock สำเร็จ');
|
||||
}
|
||||
});
|
||||
const reviseMutation = useMutation({
|
||||
...baseReviseMutation,
|
||||
onSuccess: async (...args) => {
|
||||
await baseReviseMutation.onSuccess?.(...args);
|
||||
toast.success('สร้าง revision mock สำเร็จ');
|
||||
}
|
||||
});
|
||||
const markStatusMutation = useMutation({
|
||||
...baseMarkStatusMutation,
|
||||
onSuccess: async (...args) => {
|
||||
await baseMarkStatusMutation.onSuccess?.(...args);
|
||||
toast.success('อัปเดตสถานะ mock สำเร็จ');
|
||||
}
|
||||
});
|
||||
|
||||
const ownerCustomer = data.customers.find((customer) =>
|
||||
data.quotation.customerRoles.some(
|
||||
(role) => role.role === 'owner' && role.customerId === customer.id
|
||||
)
|
||||
);
|
||||
const ownerContact = data.contacts.find((contact) =>
|
||||
data.quotation.customerRoles.some(
|
||||
(role) => role.role === 'owner' && role.contactId === contact.id
|
||||
)
|
||||
);
|
||||
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
<SectionCard
|
||||
title={data.quotation.project}
|
||||
description={`${data.quotation.code} / ${data.branch.name}`}
|
||||
action={
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
isLoading={submitApproval.isPending}
|
||||
onClick={() => submitApproval.mutate(data.quotation.id)}
|
||||
>
|
||||
Submit approval
|
||||
</Button>
|
||||
<ApprovalDialog
|
||||
title='Approve quotation'
|
||||
description='Mock sequential approval for demo.'
|
||||
actionLabel='Approve'
|
||||
isPending={approveMutation.isPending}
|
||||
onConfirm={(comment) =>
|
||||
approveMutation.mutate({ quotationId: data.quotation.id, comment })
|
||||
}
|
||||
/>
|
||||
<ApprovalDialog
|
||||
title='Reject quotation'
|
||||
description='Mock reject flow for demo.'
|
||||
actionLabel='Reject'
|
||||
isPending={rejectMutation.isPending}
|
||||
onConfirm={(comment) =>
|
||||
rejectMutation.mutate({ quotationId: data.quotation.id, comment })
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
variant='outline'
|
||||
isLoading={reviseMutation.isPending}
|
||||
onClick={() => reviseMutation.mutate(data.quotation.id)}
|
||||
>
|
||||
Create revision
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
isLoading={markStatusMutation.isPending}
|
||||
onClick={() =>
|
||||
markStatusMutation.mutate({
|
||||
quotationId: data.quotation.id,
|
||||
status: 'sent'
|
||||
})
|
||||
}
|
||||
>
|
||||
Send to customer
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
isLoading={markStatusMutation.isPending}
|
||||
onClick={() =>
|
||||
markStatusMutation.mutate({
|
||||
quotationId: data.quotation.id,
|
||||
status: 'accepted'
|
||||
})
|
||||
}
|
||||
>
|
||||
Mark accepted
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
isLoading={markStatusMutation.isPending}
|
||||
onClick={() =>
|
||||
markStatusMutation.mutate({
|
||||
quotationId: data.quotation.id,
|
||||
status: 'lost'
|
||||
})
|
||||
}
|
||||
>
|
||||
Mark lost
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className='flex flex-wrap items-center gap-3'>
|
||||
<QuotationStatusBadge status={data.quotation.status} />
|
||||
<span className='text-muted-foreground text-sm'>
|
||||
Revision R{String(data.quotation.revision).padStart(2, '0')}
|
||||
</span>
|
||||
<span className='text-sm font-medium'>
|
||||
{formatCurrency(data.quotation.totalAmount)}
|
||||
</span>
|
||||
</div>
|
||||
<div className='mt-4'>
|
||||
<InfoGrid
|
||||
cols={4}
|
||||
items={[
|
||||
{ label: 'Quotation Date', value: data.quotation.quotationDate },
|
||||
{ label: 'Valid Until', value: data.quotation.validUntil },
|
||||
{ label: 'Type', value: data.quotation.quotationType },
|
||||
{ label: 'Chance', value: `${data.quotation.chancePercent}%` },
|
||||
{ label: 'Salesman', value: data.salesman.name },
|
||||
{ label: 'Sales Admin', value: data.saleAdmin.name },
|
||||
{ label: 'Branch', value: data.branch.name },
|
||||
{ label: 'Site', value: data.quotation.siteLocation }
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard title='Customer Roles'>
|
||||
<CustomerRoleCards
|
||||
roles={data.quotation.customerRoles}
|
||||
customers={data.customers}
|
||||
contacts={data.contacts}
|
||||
/>
|
||||
</SectionCard>
|
||||
|
||||
<div className='grid gap-4 xl:grid-cols-12'>
|
||||
<SectionCard title='Item Table' className='xl:col-span-7'>
|
||||
<div className='space-y-3'>
|
||||
{data.quotation.items.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className='grid gap-2 rounded-lg border p-3 md:grid-cols-[1fr_auto_auto]'
|
||||
>
|
||||
<div>
|
||||
<p className='font-medium'>{item.description}</p>
|
||||
<p className='text-muted-foreground text-sm'>{item.topic}</p>
|
||||
</div>
|
||||
<p className='text-sm'>
|
||||
{item.quantity} {item.unit}
|
||||
</p>
|
||||
<p className='text-sm font-semibold'>{formatCurrency(item.amount)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SectionCard>
|
||||
<SectionCard title='Total Summary' className='xl:col-span-5'>
|
||||
<InfoGrid
|
||||
cols={2}
|
||||
items={[
|
||||
{ label: 'Sub Total', value: formatCurrency(data.quotation.totalAmount) },
|
||||
{ label: 'VAT 7%', value: formatCurrency(data.quotation.totalAmount * 0.07) },
|
||||
{ label: 'Grand Total', value: formatCurrency(data.quotation.totalAmount * 1.07) },
|
||||
{ label: 'Hot Project', value: data.quotation.isHotProject ? 'Yes' : 'No' }
|
||||
]}
|
||||
/>
|
||||
</SectionCard>
|
||||
</div>
|
||||
|
||||
<div className='grid gap-4 xl:grid-cols-12'>
|
||||
<SectionCard title='Topics' className='xl:col-span-5'>
|
||||
<div className='space-y-4'>
|
||||
{data.quotation.topics.map((topic) => (
|
||||
<div key={topic.id}>
|
||||
<p className='font-medium capitalize'>{topic.label}</p>
|
||||
<ul className='text-muted-foreground mt-2 space-y-1 text-sm'>
|
||||
{topic.items.map((item) => (
|
||||
<li key={item}>- {item}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard title='Attachments' className='xl:col-span-3'>
|
||||
<div className='space-y-3'>
|
||||
{data.quotation.attachments.map((attachment) => (
|
||||
<div key={attachment.id} className='rounded-lg border p-3 text-sm'>
|
||||
<p className='font-medium'>{attachment.fileName}</p>
|
||||
<p className='text-muted-foreground'>{attachment.fileType.toUpperCase()}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard title='Follow-ups' className='xl:col-span-4'>
|
||||
<TimelineList
|
||||
items={data.quotation.followUps.map((followUp) => ({
|
||||
id: followUp.id,
|
||||
title: followUp.title,
|
||||
detail: `Owner: ${followUp.ownerName}`,
|
||||
timestamp: followUp.dueDate,
|
||||
tone: followUp.status === 'done' ? 'success' : 'default'
|
||||
}))}
|
||||
/>
|
||||
</SectionCard>
|
||||
</div>
|
||||
|
||||
<div className='grid gap-4 xl:grid-cols-12'>
|
||||
<SectionCard title='Approval Timeline' className='xl:col-span-5'>
|
||||
<TimelineList
|
||||
items={data.quotation.approvalSteps.map((step) => ({
|
||||
id: step.id,
|
||||
title: `Level ${step.level} - ${step.approverName}`,
|
||||
detail: `${step.approverPosition}${step.comment ? ` / ${step.comment}` : ''}`,
|
||||
timestamp: step.actedAt ?? data.quotation.quotationDate,
|
||||
tone:
|
||||
step.status === 'approved'
|
||||
? 'success'
|
||||
: step.status === 'rejected'
|
||||
? 'danger'
|
||||
: 'default'
|
||||
}))}
|
||||
/>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard title='Audit Timeline' className='xl:col-span-7'>
|
||||
<AuditTimeline events={data.auditEvents} />
|
||||
</SectionCard>
|
||||
</div>
|
||||
|
||||
<QuotationPreviewPanel
|
||||
quotation={data.quotation}
|
||||
ownerCustomer={ownerCustomer}
|
||||
ownerContact={ownerContact}
|
||||
approvalSteps={data.quotation.approvalSteps}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
252
src/features/crm/components/quotations-table.tsx
Normal file
252
src/features/crm/components/quotations-table.tsx
Normal file
@@ -0,0 +1,252 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { type ColumnDef } from "@tanstack/react-table";
|
||||
import { parseAsInteger, parseAsString, useQueryStates } from "nuqs";
|
||||
import { getSortingStateParser } from "@/lib/parsers";
|
||||
import { useDataTable } from "@/hooks/use-data-table";
|
||||
import { DataTable } from "@/components/ui/table/data-table";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { HotProjectPill, QuotationStatusBadge } from "./shared";
|
||||
import {
|
||||
crmReferenceQueryOptions,
|
||||
quotationsQueryOptions,
|
||||
} from "../api/queries";
|
||||
import type { Quotation } from "../api/types";
|
||||
import { formatCurrency } from "../utils/format";
|
||||
|
||||
const columns: ColumnDef<Quotation>[] = [
|
||||
{ accessorKey: "code", header: "Code" },
|
||||
{ accessorKey: "project", header: "Project" },
|
||||
{ accessorKey: "quotationType", header: "Type" },
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: "Status",
|
||||
cell: ({ row }) => <QuotationStatusBadge status={row.original.status} />,
|
||||
},
|
||||
{ accessorKey: "revision", header: "Rev" },
|
||||
{
|
||||
accessorKey: "totalAmount",
|
||||
header: "Amount",
|
||||
cell: ({ row }) => formatCurrency(row.original.totalAmount),
|
||||
},
|
||||
{
|
||||
accessorKey: "isHotProject",
|
||||
header: "Hot",
|
||||
cell: ({ row }) => <HotProjectPill active={row.original.isHotProject} />,
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "",
|
||||
cell: ({ row }) => (
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href={`/dashboard/crm/quotations/${row.original.id}`}>
|
||||
View
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const columnIds = columns
|
||||
.map((column) => column.id ?? String(column.accessorKey))
|
||||
.filter(Boolean);
|
||||
|
||||
function KanbanView({ items }: { items: Quotation[] }) {
|
||||
const statuses = [
|
||||
"draft",
|
||||
"pending_approval",
|
||||
"approved",
|
||||
"sent",
|
||||
"accepted",
|
||||
"lost",
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 xl:grid-cols-6">
|
||||
{statuses.map((status) => (
|
||||
<Card key={status} className="space-y-3 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-semibold capitalize">
|
||||
{status.replace("_", " ")}
|
||||
</p>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{items.filter((item) => item.status === status).length}
|
||||
</span>
|
||||
</div>
|
||||
{items
|
||||
.filter((item) => item.status === status)
|
||||
.map((item) => (
|
||||
<Link
|
||||
key={item.id}
|
||||
href={`/dashboard/crm/quotations/${item.id}`}
|
||||
className="block rounded-lg border p-3 text-sm transition-colors hover:bg-muted/40"
|
||||
>
|
||||
<p className="font-medium">{item.code}</p>
|
||||
<p className="text-muted-foreground mt-1 line-clamp-2">
|
||||
{item.project}
|
||||
</p>
|
||||
<p className="mt-2 font-semibold">
|
||||
{formatCurrency(item.totalAmount)}
|
||||
</p>
|
||||
</Link>
|
||||
))}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function QuotationsTablePage() {
|
||||
const [params, setParams] = useQueryStates({
|
||||
page: parseAsInteger.withDefault(1),
|
||||
perPage: parseAsInteger.withDefault(10),
|
||||
name: parseAsString,
|
||||
status: parseAsString,
|
||||
quotationType: parseAsString,
|
||||
salesman: parseAsString,
|
||||
hot: parseAsString,
|
||||
dateFrom: parseAsString,
|
||||
dateTo: parseAsString,
|
||||
view: parseAsString.withDefault("table"),
|
||||
sort: getSortingStateParser(columnIds).withDefault([]),
|
||||
});
|
||||
const { data: reference } = useSuspenseQuery(crmReferenceQueryOptions());
|
||||
|
||||
const filters = {
|
||||
page: params.page,
|
||||
limit: params.perPage,
|
||||
...(params.name && { search: params.name }),
|
||||
...(params.status && { status: params.status }),
|
||||
...(params.quotationType && { quotationType: params.quotationType }),
|
||||
...(params.salesman && { salesman: params.salesman }),
|
||||
...(params.hot && { hot: params.hot }),
|
||||
...(params.dateFrom && { dateFrom: params.dateFrom }),
|
||||
...(params.dateTo && { dateTo: params.dateTo }),
|
||||
...(params.sort.length ? { sort: JSON.stringify(params.sort) } : {}),
|
||||
};
|
||||
|
||||
const { data } = useSuspenseQuery(quotationsQueryOptions(filters));
|
||||
const pageCount = Math.ceil(data.totalItems / params.perPage);
|
||||
const { table } = useDataTable({
|
||||
data: data.items,
|
||||
columns,
|
||||
pageCount,
|
||||
initialState: {
|
||||
columnPinning: { right: ["actions"] },
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col space-y-4">
|
||||
<div className="flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-7 xl:w-full">
|
||||
<Input
|
||||
placeholder="ค้นหา code หรือ project"
|
||||
value={params.name ?? ""}
|
||||
onChange={(event) =>
|
||||
void setParams({ name: event.target.value || null, page: 1 })
|
||||
}
|
||||
/>
|
||||
<select
|
||||
className="border-input bg-background rounded-md border px-3 text-sm"
|
||||
value={params.status ?? ""}
|
||||
onChange={(event) =>
|
||||
void setParams({ status: event.target.value || null, page: 1 })
|
||||
}
|
||||
>
|
||||
<option value="">ทุกสถานะ</option>
|
||||
<option value="draft">Draft</option>
|
||||
<option value="pending_approval">Pending Approval</option>
|
||||
<option value="approved">Approved</option>
|
||||
<option value="sent">Sent</option>
|
||||
<option value="accepted">Accepted</option>
|
||||
</select>
|
||||
<select
|
||||
className="border-input bg-background rounded-md border px-3 text-sm"
|
||||
value={params.quotationType ?? ""}
|
||||
onChange={(event) =>
|
||||
void setParams({
|
||||
quotationType: event.target.value || null,
|
||||
page: 1,
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="">ทุกประเภท</option>
|
||||
<option value="official">Official</option>
|
||||
<option value="budgetary">Budgetary</option>
|
||||
<option value="service">Service</option>
|
||||
</select>
|
||||
<select
|
||||
className="border-input bg-background rounded-md border px-3 text-sm"
|
||||
value={params.salesman ?? ""}
|
||||
onChange={(event) =>
|
||||
void setParams({ salesman: event.target.value || null, page: 1 })
|
||||
}
|
||||
>
|
||||
<option value="">ทุก salesperson</option>
|
||||
{reference.salespersons.map((sales) => (
|
||||
<option key={sales.id} value={sales.id}>
|
||||
{sales.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className="border-input bg-background rounded-md border px-3 text-sm"
|
||||
value={params.hot ?? ""}
|
||||
onChange={(event) =>
|
||||
void setParams({ hot: event.target.value || null, page: 1 })
|
||||
}
|
||||
>
|
||||
<option value="">ทุกดีล</option>
|
||||
<option value="yes">Hot only</option>
|
||||
<option value="no">Non-hot</option>
|
||||
</select>
|
||||
<Input
|
||||
type="date"
|
||||
value={params.dateFrom ?? ""}
|
||||
onChange={(event) =>
|
||||
void setParams({ dateFrom: event.target.value || null, page: 1 })
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
type="date"
|
||||
value={params.dateTo ?? ""}
|
||||
onChange={(event) =>
|
||||
void setParams({ dateTo: event.target.value || null, page: 1 })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/dashboard/crm/enquiries">Create quotation</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant={params.view === "table" ? "default" : "outline"}
|
||||
onClick={() => void setParams({ view: "table" })}
|
||||
>
|
||||
Table
|
||||
</Button>
|
||||
<Button
|
||||
variant={params.view === "kanban" ? "default" : "outline"}
|
||||
onClick={() => void setParams({ view: "kanban" })}
|
||||
>
|
||||
Kanban
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{params.view === "kanban" ? (
|
||||
<KanbanView items={data.items} />
|
||||
) : (
|
||||
<DataTable table={table} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
79
src/features/crm/components/settings-page.tsx
Normal file
79
src/features/crm/components/settings-page.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
'use client';
|
||||
|
||||
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { crmReferenceQueryOptions } from '../api/queries';
|
||||
import { EmptyState, SectionCard } from './shared';
|
||||
|
||||
export function MasterOptionsPage() {
|
||||
const { data } = useSuspenseQuery(crmReferenceQueryOptions());
|
||||
|
||||
return (
|
||||
<SectionCard title='Master Options' description='Status, product type, payment term, currency, tax rate'>
|
||||
<div className='grid gap-3 md:grid-cols-2 xl:grid-cols-3'>
|
||||
{data.masterOptions.map((item) => (
|
||||
<div key={item.id} className='rounded-lg border p-4'>
|
||||
<p className='text-muted-foreground text-xs uppercase tracking-[0.2em]'>{item.group}</p>
|
||||
<p className='mt-2 font-medium'>{item.label}</p>
|
||||
<p className='text-muted-foreground text-sm'>{item.code}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
export function DocumentSequencesPage() {
|
||||
const { data } = useSuspenseQuery(crmReferenceQueryOptions());
|
||||
|
||||
return (
|
||||
<SectionCard title='Document Sequences' description='Mock document sequence by branch'>
|
||||
<div className='space-y-3'>
|
||||
{data.sequences.map((item) => (
|
||||
<div key={item.id} className='rounded-lg border p-4'>
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<p className='font-medium'>
|
||||
{item.prefix}{item.period}-{String(item.currentNumber).padStart(item.paddingLength, '0')}
|
||||
</p>
|
||||
<p className='text-muted-foreground text-sm'>{item.documentType}</p>
|
||||
</div>
|
||||
<p className='text-muted-foreground mt-1 text-sm'>Branch: {item.branchId}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
export function TemplatesPage() {
|
||||
const { data } = useSuspenseQuery(crmReferenceQueryOptions());
|
||||
|
||||
if (data.templates.length === 0) {
|
||||
return <EmptyState title='No templates' description='ยังไม่มี quotation templates' />;
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionCard title='Quotation Templates' description='Mock template and placeholder mappings'>
|
||||
<div className='space-y-4'>
|
||||
{data.templates.map((template) => (
|
||||
<div key={template.id} className='rounded-xl border p-4'>
|
||||
<div className='flex flex-wrap items-center justify-between gap-3'>
|
||||
<div>
|
||||
<p className='font-semibold'>{template.name}</p>
|
||||
<p className='text-muted-foreground text-sm'>{template.description}</p>
|
||||
</div>
|
||||
<p className='text-muted-foreground text-sm'>{template.version}</p>
|
||||
</div>
|
||||
<div className='mt-3 grid gap-3 md:grid-cols-2'>
|
||||
{template.mappings.map((mapping) => (
|
||||
<div key={mapping.id} className='rounded-lg bg-muted/20 p-3 text-sm'>
|
||||
<p className='font-medium'>{mapping.placeholder}</p>
|
||||
<p className='text-muted-foreground'>{mapping.sourceField}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
367
src/features/crm/components/shared.tsx
Normal file
367
src/features/crm/components/shared.tsx
Normal file
@@ -0,0 +1,367 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Icons } from '@/components/icons';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type {
|
||||
ApprovalStep,
|
||||
AuditEvent,
|
||||
CrmBranch,
|
||||
Customer,
|
||||
CustomerContact,
|
||||
Enquiry,
|
||||
Quotation,
|
||||
QuotationCustomerRole
|
||||
} from '../api/types';
|
||||
import { formatCurrency, formatDate, formatPercent } from '../utils/format';
|
||||
import {
|
||||
getCustomerStatusLabel,
|
||||
getEnquiryStatusLabel,
|
||||
getQuotationStatusLabel
|
||||
} from '../utils/status';
|
||||
|
||||
export function SectionCard({
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
children,
|
||||
className
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader className='flex flex-row items-start justify-between space-y-0'>
|
||||
<div className='space-y-1'>
|
||||
<CardTitle className='text-base'>{title}</CardTitle>
|
||||
{description ? <CardDescription>{description}</CardDescription> : null}
|
||||
</div>
|
||||
{action}
|
||||
</CardHeader>
|
||||
<CardContent>{children}</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function KpiCard({
|
||||
label,
|
||||
value,
|
||||
change,
|
||||
trend
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
change: string;
|
||||
trend: 'up' | 'down' | 'flat';
|
||||
}) {
|
||||
const TrendIcon =
|
||||
trend === 'up' ? Icons.trendingUp : trend === 'down' ? Icons.trendingDown : Icons.clock;
|
||||
|
||||
return (
|
||||
<Card className='bg-gradient-to-br from-card via-card to-muted/40'>
|
||||
<CardHeader className='pb-3'>
|
||||
<div className='flex items-start justify-between gap-3'>
|
||||
<CardDescription>{label}</CardDescription>
|
||||
<Badge variant='outline'>
|
||||
<TrendIcon className='mr-1 h-3.5 w-3.5' />
|
||||
{change}
|
||||
</Badge>
|
||||
</div>
|
||||
<CardTitle className='text-2xl'>{value}</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function BranchBadge({ branch }: { branch: CrmBranch }) {
|
||||
return <Badge variant='secondary'>{branch.code}</Badge>;
|
||||
}
|
||||
|
||||
export function CustomerStatusBadge({ status }: { status: Customer['customerStatus'] }) {
|
||||
return (
|
||||
<Badge variant={status === 'inactive' ? 'destructive' : status === 'prospect' ? 'outline' : 'secondary'}>
|
||||
{getCustomerStatusLabel(status)}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export function EnquiryStatusBadge({ status }: { status: Enquiry['status'] }) {
|
||||
return (
|
||||
<Badge
|
||||
variant={
|
||||
status === 'cancelled' || status === 'closed_lost'
|
||||
? 'destructive'
|
||||
: status === 'converted'
|
||||
? 'secondary'
|
||||
: 'outline'
|
||||
}
|
||||
>
|
||||
{getEnquiryStatusLabel(status)}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export function QuotationStatusBadge({ status }: { status: Quotation['status'] }) {
|
||||
return (
|
||||
<Badge
|
||||
variant={
|
||||
status === 'accepted' || status === 'approved'
|
||||
? 'secondary'
|
||||
: status === 'lost' || status === 'rejected' || status === 'cancelled'
|
||||
? 'destructive'
|
||||
: 'outline'
|
||||
}
|
||||
>
|
||||
{getQuotationStatusLabel(status)}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export function InfoGrid({
|
||||
items,
|
||||
cols = 2
|
||||
}: {
|
||||
items: Array<{ label: string; value: React.ReactNode }>;
|
||||
cols?: 2 | 3 | 4;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('grid gap-4', cols === 2 && 'md:grid-cols-2', cols === 3 && 'md:grid-cols-3', cols === 4 && 'md:grid-cols-2 xl:grid-cols-4')}>
|
||||
{items.map((item) => (
|
||||
<div key={item.label} className='rounded-lg border bg-muted/20 p-3'>
|
||||
<p className='text-muted-foreground text-xs uppercase tracking-[0.18em]'>{item.label}</p>
|
||||
<div className='mt-1 text-sm font-medium'>{item.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TimelineList({
|
||||
items
|
||||
}: {
|
||||
items: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
detail: string;
|
||||
actorName?: string;
|
||||
timestamp: string;
|
||||
tone?: 'default' | 'success' | 'danger';
|
||||
}>;
|
||||
}) {
|
||||
return (
|
||||
<div className='space-y-3'>
|
||||
{items.map((item) => (
|
||||
<div key={item.id} className='flex gap-3'>
|
||||
<div className='mt-1 flex flex-col items-center'>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex h-2.5 w-2.5 rounded-full bg-primary',
|
||||
item.tone === 'success' && 'bg-emerald-500',
|
||||
item.tone === 'danger' && 'bg-destructive'
|
||||
)}
|
||||
/>
|
||||
<span className='bg-border mt-1 h-full w-px' />
|
||||
</div>
|
||||
<div className='pb-3'>
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<p className='text-sm font-medium'>{item.title}</p>
|
||||
<p className='text-muted-foreground text-xs'>{formatDate(item.timestamp)}</p>
|
||||
</div>
|
||||
<p className='text-muted-foreground text-sm'>{item.detail}</p>
|
||||
{item.actorName ? <p className='mt-1 text-xs'>โดย {item.actorName}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CustomerRoleCards({
|
||||
roles,
|
||||
customers,
|
||||
contacts
|
||||
}: {
|
||||
roles: QuotationCustomerRole[];
|
||||
customers: Customer[];
|
||||
contacts: CustomerContact[];
|
||||
}) {
|
||||
return (
|
||||
<div className='grid gap-3 md:grid-cols-2 xl:grid-cols-4'>
|
||||
{roles.map((role) => {
|
||||
const customer = customers.find((item) => item.id === role.customerId);
|
||||
const contact = contacts.find((item) => item.id === role.contactId);
|
||||
|
||||
return (
|
||||
<div key={`${role.role}-${role.customerId}`} className='rounded-lg border bg-muted/20 p-4'>
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<p className='text-sm font-semibold capitalize'>{role.role}</p>
|
||||
<Badge variant='outline'>{customer?.code}</Badge>
|
||||
</div>
|
||||
<p className='mt-2 text-sm font-medium'>{customer?.name}</p>
|
||||
<p className='text-muted-foreground text-sm'>{contact?.name ?? 'No contact'}</p>
|
||||
<p className='text-muted-foreground text-xs'>{contact?.position}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function QuotationPreviewPanel({
|
||||
quotation,
|
||||
ownerCustomer,
|
||||
ownerContact,
|
||||
approvalSteps
|
||||
}: {
|
||||
quotation: Quotation;
|
||||
ownerCustomer?: Customer;
|
||||
ownerContact?: CustomerContact;
|
||||
approvalSteps: ApprovalStep[];
|
||||
}) {
|
||||
return (
|
||||
<SectionCard title='Quotation Preview' description='Mock preview พร้อมต่อยอดไป pdfme/template mapping'>
|
||||
<div className='rounded-xl border bg-background p-5 shadow-sm'>
|
||||
<div className='flex flex-wrap items-start justify-between gap-4 border-b pb-4'>
|
||||
<div>
|
||||
<p className='text-primary text-xs uppercase tracking-[0.3em]'>ALLA OS CRM vNext</p>
|
||||
<h3 className='mt-2 text-xl font-semibold'>{quotation.code}</h3>
|
||||
<p className='text-muted-foreground text-sm'>{quotation.project}</p>
|
||||
</div>
|
||||
<div className='space-y-1 text-sm'>
|
||||
<p><span className='text-muted-foreground'>quotation_date:</span> {quotation.quotationDate}</p>
|
||||
<p><span className='text-muted-foreground'>valid_until:</span> {quotation.validUntil}</p>
|
||||
<p><span className='text-muted-foreground'>currency:</span> THB</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='grid gap-4 py-4 md:grid-cols-2'>
|
||||
<div className='space-y-1 text-sm'>
|
||||
<p className='font-medium'>customer_name</p>
|
||||
<p>{ownerCustomer?.name}</p>
|
||||
<p className='text-muted-foreground'>{ownerCustomer?.address}</p>
|
||||
<p className='text-muted-foreground'>{ownerCustomer?.phone}</p>
|
||||
<p className='text-muted-foreground'>{ownerCustomer?.email}</p>
|
||||
</div>
|
||||
<div className='space-y-1 text-sm'>
|
||||
<p className='font-medium'>customer_att</p>
|
||||
<p>{ownerContact?.name ?? '-'}</p>
|
||||
<p className='text-muted-foreground'>{ownerContact?.position ?? '-'}</p>
|
||||
<p className='text-muted-foreground'>{quotation.siteLocation}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='space-y-3 border-t pt-4'>
|
||||
<div>
|
||||
<p className='text-sm font-medium'>item_topic</p>
|
||||
<div className='mt-2 space-y-2'>
|
||||
{quotation.items.map((item) => (
|
||||
<div key={item.id} className='flex items-center justify-between gap-4 rounded-lg border p-3 text-sm'>
|
||||
<div>
|
||||
<p className='font-medium'>{item.description}</p>
|
||||
<p className='text-muted-foreground'>
|
||||
{item.quantity} {item.unit} x {formatCurrency(item.unitPrice)}
|
||||
</p>
|
||||
</div>
|
||||
<p className='font-semibold'>{formatCurrency(item.amount)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className='rounded-lg bg-muted/30 p-3 text-sm'>
|
||||
<p className='font-medium'>exclusion_data</p>
|
||||
<ul className='text-muted-foreground mt-1 space-y-1'>
|
||||
{quotation.topics
|
||||
.find((topic) => topic.type === 'exclusion')
|
||||
?.items.map((item) => <li key={item}>- {item}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
<div className='flex items-center justify-between rounded-lg border border-dashed p-3 text-sm'>
|
||||
<p className='font-medium'>quotation_price</p>
|
||||
<p className='text-lg font-semibold'>{formatCurrency(quotation.totalAmount)}</p>
|
||||
</div>
|
||||
<div className='rounded-lg bg-muted/30 p-3 text-sm'>
|
||||
<p className='font-medium'>approver names and positions</p>
|
||||
<ul className='text-muted-foreground mt-1 space-y-1'>
|
||||
{approvalSteps.map((step) => (
|
||||
<li key={step.id}>
|
||||
{step.approverName} / {step.approverPosition}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
export function RelatedQuotationList({ quotations }: { quotations: Quotation[] }) {
|
||||
return (
|
||||
<div className='space-y-3'>
|
||||
{quotations.map((quotation) => (
|
||||
<div key={quotation.id} className='flex flex-wrap items-center justify-between gap-3 rounded-lg border p-3'>
|
||||
<div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<p className='font-medium'>{quotation.code}</p>
|
||||
<QuotationStatusBadge status={quotation.status} />
|
||||
</div>
|
||||
<p className='text-muted-foreground text-sm'>{quotation.project}</p>
|
||||
</div>
|
||||
<div className='flex items-center gap-3'>
|
||||
<p className='text-sm font-medium'>{formatCurrency(quotation.totalAmount)}</p>
|
||||
<Button variant='outline' asChild size='sm'>
|
||||
<Link href={`/dashboard/crm/quotations/${quotation.id}`}>
|
||||
<Icons.arrowRight className='mr-1 h-4 w-4' />
|
||||
เปิด
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function HotProjectPill({ active }: { active: boolean }) {
|
||||
return active ? <Badge>Hot</Badge> : <Badge variant='outline'>Normal</Badge>;
|
||||
}
|
||||
|
||||
export function ChancePill({ value }: { value: number }) {
|
||||
return <Badge variant='outline'>{formatPercent(value)}</Badge>;
|
||||
}
|
||||
|
||||
export function AuditTimeline({ events }: { events: AuditEvent[] }) {
|
||||
return (
|
||||
<TimelineList
|
||||
items={events.map((event) => ({
|
||||
id: event.id,
|
||||
title: event.action,
|
||||
detail: event.detail,
|
||||
actorName: event.actorName,
|
||||
timestamp: event.createdAt,
|
||||
tone:
|
||||
event.action === 'APPROVE'
|
||||
? 'success'
|
||||
: event.action === 'REJECT'
|
||||
? 'danger'
|
||||
: 'default'
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function EmptyState({ title, description }: { title: string; description: string }) {
|
||||
return (
|
||||
<div className='rounded-lg border border-dashed p-8 text-center'>
|
||||
<p className='font-medium'>{title}</p>
|
||||
<p className='text-muted-foreground mt-1 text-sm'>{description}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
731
src/features/crm/data/mock-crm.ts
Normal file
731
src/features/crm/data/mock-crm.ts
Normal file
@@ -0,0 +1,731 @@
|
||||
import type {
|
||||
ApprovalItem,
|
||||
AuditEvent,
|
||||
ContactShare,
|
||||
ContactShareLog,
|
||||
CrmBranch,
|
||||
CrmSalesperson,
|
||||
Customer,
|
||||
CustomerContact,
|
||||
DocumentSequence,
|
||||
Enquiry,
|
||||
MasterOption,
|
||||
Quotation,
|
||||
QuotationTemplate
|
||||
} from '../api/types';
|
||||
|
||||
export interface CrmMockState {
|
||||
branches: CrmBranch[];
|
||||
salespersons: CrmSalesperson[];
|
||||
customers: Customer[];
|
||||
contacts: CustomerContact[];
|
||||
contactShares: ContactShare[];
|
||||
contactShareLogs: ContactShareLog[];
|
||||
enquiries: Enquiry[];
|
||||
quotations: Quotation[];
|
||||
approvals: ApprovalItem[];
|
||||
auditEvents: AuditEvent[];
|
||||
sequences: DocumentSequence[];
|
||||
masterOptions: MasterOption[];
|
||||
templates: QuotationTemplate[];
|
||||
}
|
||||
|
||||
export const initialCrmState: CrmMockState = {
|
||||
branches: [
|
||||
{ id: 'bkk', code: 'BKK', name: 'Bangkok HQ', region: 'Central' },
|
||||
{ id: 'chn', code: 'CHN', name: 'Chiang Mai Branch', region: 'North' }
|
||||
],
|
||||
salespersons: [
|
||||
{ id: 'sp-1', name: 'Krit S.', nickname: 'Krit', branchId: 'bkk', role: 'sales' },
|
||||
{ id: 'sp-2', name: 'Nicha P.', nickname: 'Nicha', branchId: 'bkk', role: 'sale_admin' },
|
||||
{ id: 'sp-3', name: 'Ton A.', nickname: 'Ton', branchId: 'chn', role: 'sales' }
|
||||
],
|
||||
customers: [
|
||||
{
|
||||
id: 'cus-1',
|
||||
code: 'CUS-BKK-001',
|
||||
name: 'Siam Metro Development',
|
||||
taxId: '0105556100011',
|
||||
address: '88 Rama 9 Road',
|
||||
province: 'Bangkok',
|
||||
district: 'Huai Khwang',
|
||||
subDistrict: 'Bang Kapi',
|
||||
postalCode: '10310',
|
||||
phone: '02-555-1001',
|
||||
fax: '02-555-1999',
|
||||
email: 'procurement@siammetro.co.th',
|
||||
customerType: 'developer',
|
||||
customerStatus: 'active',
|
||||
branchId: 'bkk',
|
||||
contactIds: ['ct-1', 'ct-2']
|
||||
},
|
||||
{
|
||||
id: 'cus-2',
|
||||
code: 'CUS-BKK-002',
|
||||
name: 'Prime Lift Engineering',
|
||||
taxId: '0105556100012',
|
||||
address: '19 Vibhavadi Rangsit',
|
||||
province: 'Bangkok',
|
||||
district: 'Chatuchak',
|
||||
subDistrict: 'Chom Phon',
|
||||
postalCode: '10900',
|
||||
phone: '02-555-2002',
|
||||
email: 'sales@primelift.co.th',
|
||||
customerType: 'contractor',
|
||||
customerStatus: 'active',
|
||||
branchId: 'bkk',
|
||||
contactIds: ['ct-3', 'ct-4']
|
||||
},
|
||||
{
|
||||
id: 'cus-3',
|
||||
code: 'CUS-CHN-003',
|
||||
name: 'Northern Cold Chain',
|
||||
taxId: '0505556100013',
|
||||
address: '125 Super Highway',
|
||||
province: 'Chiang Mai',
|
||||
district: 'Mueang',
|
||||
subDistrict: 'Wat Ket',
|
||||
postalCode: '50000',
|
||||
phone: '053-555-3003',
|
||||
email: 'project@ncc.co.th',
|
||||
customerType: 'owner',
|
||||
customerStatus: 'prospect',
|
||||
branchId: 'chn',
|
||||
contactIds: ['ct-5', 'ct-6']
|
||||
},
|
||||
{
|
||||
id: 'cus-4',
|
||||
code: 'CUS-BKK-004',
|
||||
name: 'Urban Dock Solution',
|
||||
taxId: '0105556100014',
|
||||
address: '77 Bangna-Trad KM.8',
|
||||
province: 'Samut Prakan',
|
||||
district: 'Bang Phli',
|
||||
subDistrict: 'Bang Kaeo',
|
||||
postalCode: '10540',
|
||||
phone: '02-555-4004',
|
||||
email: 'admin@urbandock.asia',
|
||||
customerType: 'consultant',
|
||||
customerStatus: 'active',
|
||||
branchId: 'bkk',
|
||||
contactIds: ['ct-7', 'ct-8']
|
||||
},
|
||||
{
|
||||
id: 'cus-5',
|
||||
code: 'CUS-CHN-005',
|
||||
name: 'Lanna Solar Estate',
|
||||
taxId: '0505556100015',
|
||||
address: '199 Ring Road',
|
||||
province: 'Chiang Mai',
|
||||
district: 'San Sai',
|
||||
subDistrict: 'Nong Chom',
|
||||
postalCode: '50210',
|
||||
phone: '053-555-5005',
|
||||
email: 'energy@lannasolar.co.th',
|
||||
customerType: 'developer',
|
||||
customerStatus: 'inactive',
|
||||
branchId: 'chn',
|
||||
contactIds: ['ct-9', 'ct-10']
|
||||
}
|
||||
],
|
||||
contacts: [
|
||||
{ id: 'ct-1', customerId: 'cus-1', name: 'Ploy Tantip', position: 'Procurement Manager', email: 'ploy@siammetro.co.th', phone: '081-111-1111', isPrimary: true },
|
||||
{ id: 'ct-2', customerId: 'cus-1', name: 'Vee Chan', position: 'Project Engineer', email: 'vee@siammetro.co.th', phone: '081-111-1112' },
|
||||
{ id: 'ct-3', customerId: 'cus-2', name: 'Boss K.', position: 'Managing Director', email: 'boss@primelift.co.th', phone: '082-222-2221', isPrimary: true },
|
||||
{ id: 'ct-4', customerId: 'cus-2', name: 'Jane R.', position: 'Estimator', email: 'jane@primelift.co.th', phone: '082-222-2222' },
|
||||
{ id: 'ct-5', customerId: 'cus-3', name: 'Aon M.', position: 'Operations Head', email: 'aon@ncc.co.th', phone: '083-333-3331', isPrimary: true },
|
||||
{ id: 'ct-6', customerId: 'cus-3', name: 'Max P.', position: 'Warehouse Lead', email: 'max@ncc.co.th', phone: '083-333-3332' },
|
||||
{ id: 'ct-7', customerId: 'cus-4', name: 'Mint T.', position: 'Design Consultant', email: 'mint@urbandock.asia', phone: '084-444-4441', isPrimary: true },
|
||||
{ id: 'ct-8', customerId: 'cus-4', name: 'Ken D.', position: 'Project Coordinator', email: 'ken@urbandock.asia', phone: '084-444-4442' },
|
||||
{ id: 'ct-9', customerId: 'cus-5', name: 'Fah N.', position: 'Energy Planning Lead', email: 'fah@lannasolar.co.th', phone: '085-555-5551', isPrimary: true },
|
||||
{ id: 'ct-10', customerId: 'cus-5', name: 'Palm J.', position: 'Plant Director', email: 'palm@lannasolar.co.th', phone: '085-555-5552' }
|
||||
],
|
||||
contactShares: [
|
||||
{ id: 'share-1', contactId: 'ct-1', sharedWithUserId: 'u-1', sharedWithUserName: 'Nicha P.', permission: 'view', createdAt: '2026-06-01T09:00:00.000Z' },
|
||||
{ id: 'share-2', contactId: 'ct-7', sharedWithUserId: 'u-2', sharedWithUserName: 'Ton A.', permission: 'edit', createdAt: '2026-06-03T10:30:00.000Z' }
|
||||
],
|
||||
contactShareLogs: [
|
||||
{ id: 'share-log-1', contactId: 'ct-1', action: 'SHARE', targetUserName: 'Nicha P.', actorName: 'Krit S.', createdAt: '2026-06-01T09:00:00.000Z' },
|
||||
{ id: 'share-log-2', contactId: 'ct-7', action: 'SHARE', targetUserName: 'Ton A.', actorName: 'Krit S.', createdAt: '2026-06-03T10:30:00.000Z' },
|
||||
{ id: 'share-log-3', contactId: 'ct-7', action: 'REVOKE', targetUserName: 'Ton A.', actorName: 'Nicha P.', createdAt: '2026-06-07T14:45:00.000Z' }
|
||||
],
|
||||
enquiries: [
|
||||
{
|
||||
id: 'enq-1',
|
||||
code: 'ENQ2606-001',
|
||||
title: 'Dock leveler replacement for Phase 3 warehouse',
|
||||
customerId: 'cus-1',
|
||||
contactId: 'ct-1',
|
||||
branchId: 'bkk',
|
||||
salesmanId: 'sp-1',
|
||||
productType: 'dockdoor',
|
||||
status: 'converted',
|
||||
requirementSummary: 'Replace 8 dock levelers with high cycle hydraulic model.',
|
||||
projectLocation: 'Bangkok Logistics Park',
|
||||
chancePercent: 82,
|
||||
competitor: 'Apex Dock',
|
||||
expectedValue: 2750000,
|
||||
dueDate: '2026-06-14',
|
||||
createdAt: '2026-05-28T09:00:00.000Z',
|
||||
updatedAt: '2026-06-09T15:20:00.000Z',
|
||||
quotationIds: ['qt-1', 'qt-2'],
|
||||
activities: [
|
||||
{ id: 'enq-1-act-1', type: 'CALL', title: 'รับ requirement เบื้องต้น', detail: 'ลูกค้าต้องการเปลี่ยนภายใน Q3', actorName: 'Krit S.', createdAt: '2026-05-28T09:00:00.000Z' },
|
||||
{ id: 'enq-1-act-2', type: 'VISIT', title: 'สำรวจหน้างาน', detail: 'หน้างานพร้อม shutdown 3 วัน', actorName: 'Krit S.', createdAt: '2026-05-30T13:30:00.000Z' },
|
||||
{ id: 'enq-1-act-3', type: 'CONVERT', title: 'Convert to quotation', detail: 'สร้าง QT2606-001', actorName: 'Nicha P.', createdAt: '2026-06-02T08:15:00.000Z' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'enq-2',
|
||||
code: 'ENQ2606-002',
|
||||
title: 'Overhead crane upgrade for fabrication line',
|
||||
customerId: 'cus-2',
|
||||
contactId: 'ct-3',
|
||||
branchId: 'bkk',
|
||||
salesmanId: 'sp-1',
|
||||
productType: 'crane',
|
||||
status: 'requirement',
|
||||
requirementSummary: '10 ton overhead crane with remote monitoring.',
|
||||
projectLocation: 'Samut Sakhon Plant',
|
||||
chancePercent: 68,
|
||||
competitor: 'LiftPro',
|
||||
expectedValue: 5200000,
|
||||
dueDate: '2026-06-20',
|
||||
createdAt: '2026-05-29T10:00:00.000Z',
|
||||
updatedAt: '2026-06-10T11:00:00.000Z',
|
||||
quotationIds: ['qt-3'],
|
||||
activities: [
|
||||
{ id: 'enq-2-act-1', type: 'MEETING', title: 'Kickoff meeting', detail: 'หารือ requirement ทางเทคนิค', actorName: 'Krit S.', createdAt: '2026-05-29T10:00:00.000Z' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'enq-3',
|
||||
code: 'ENQ2606-003',
|
||||
title: 'Solar rooftop for cold storage lot C',
|
||||
customerId: 'cus-3',
|
||||
contactId: 'ct-5',
|
||||
branchId: 'chn',
|
||||
salesmanId: 'sp-3',
|
||||
productType: 'solarcell',
|
||||
status: 'follow_up',
|
||||
requirementSummary: '500 kWp rooftop with energy monitoring.',
|
||||
projectLocation: 'Chiang Mai DC',
|
||||
chancePercent: 49,
|
||||
competitor: 'SunNorth',
|
||||
expectedValue: 6100000,
|
||||
dueDate: '2026-06-18',
|
||||
createdAt: '2026-05-27T09:20:00.000Z',
|
||||
updatedAt: '2026-06-08T16:10:00.000Z',
|
||||
quotationIds: ['qt-4'],
|
||||
activities: [
|
||||
{ id: 'enq-3-act-1', type: 'NOTE', title: 'ติดตาม BOQ', detail: 'รอฝั่งลูกค้าส่งโหลดไฟฟ้า', actorName: 'Ton A.', createdAt: '2026-06-08T16:10:00.000Z' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'enq-4',
|
||||
code: 'ENQ2606-004',
|
||||
title: 'Dock shelter package for retrofit',
|
||||
customerId: 'cus-4',
|
||||
contactId: 'ct-7',
|
||||
branchId: 'bkk',
|
||||
salesmanId: 'sp-1',
|
||||
productType: 'dockdoor',
|
||||
status: 'new',
|
||||
requirementSummary: 'Retrofit existing dock shelter 12 bays.',
|
||||
projectLocation: 'Bangna Logistics Hub',
|
||||
chancePercent: 35,
|
||||
competitor: 'Apex Dock',
|
||||
expectedValue: 1850000,
|
||||
dueDate: '2026-06-21',
|
||||
createdAt: '2026-06-05T14:00:00.000Z',
|
||||
updatedAt: '2026-06-05T14:00:00.000Z',
|
||||
quotationIds: [],
|
||||
activities: [
|
||||
{ id: 'enq-4-act-1', type: 'CALL', title: 'Lead intake', detail: 'รับ lead จาก consultant', actorName: 'Krit S.', createdAt: '2026-06-05T14:00:00.000Z' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'enq-5',
|
||||
code: 'ENQ2606-005',
|
||||
title: 'Crane preventive maintenance agreement',
|
||||
customerId: 'cus-2',
|
||||
contactId: 'ct-4',
|
||||
branchId: 'bkk',
|
||||
salesmanId: 'sp-1',
|
||||
productType: 'crane',
|
||||
status: 'qualifying',
|
||||
requirementSummary: 'Annual PM contract for 6 cranes.',
|
||||
projectLocation: 'Ayutthaya Plant',
|
||||
chancePercent: 53,
|
||||
competitor: 'LiftPro',
|
||||
expectedValue: 980000,
|
||||
dueDate: '2026-06-19',
|
||||
createdAt: '2026-06-02T11:00:00.000Z',
|
||||
updatedAt: '2026-06-06T12:00:00.000Z',
|
||||
quotationIds: ['qt-5'],
|
||||
activities: [
|
||||
{ id: 'enq-5-act-1', type: 'UPDATE', title: 'ผ่านขั้น qualifying', detail: 'ลูกค้ามี budget แล้ว', actorName: 'Krit S.', createdAt: '2026-06-06T12:00:00.000Z' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'enq-6',
|
||||
code: 'ENQ2606-006',
|
||||
title: 'Solar canopy concept study',
|
||||
customerId: 'cus-5',
|
||||
contactId: 'ct-9',
|
||||
branchId: 'chn',
|
||||
salesmanId: 'sp-3',
|
||||
productType: 'solarcell',
|
||||
status: 'closed_lost',
|
||||
requirementSummary: 'Concept study for parking canopy solar.',
|
||||
projectLocation: 'Chiang Rai Service Center',
|
||||
chancePercent: 0,
|
||||
competitor: 'GreenBeam',
|
||||
expectedValue: 2400000,
|
||||
dueDate: '2026-06-08',
|
||||
createdAt: '2026-05-20T09:00:00.000Z',
|
||||
updatedAt: '2026-06-08T18:00:00.000Z',
|
||||
quotationIds: ['qt-6'],
|
||||
activities: [
|
||||
{ id: 'enq-6-act-1', type: 'UPDATE', title: 'Lost to competitor', detail: 'ราคา competitor ต่ำกว่า', actorName: 'Ton A.', createdAt: '2026-06-08T18:00:00.000Z' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'enq-7',
|
||||
code: 'ENQ2606-007',
|
||||
title: 'High-speed door package',
|
||||
customerId: 'cus-1',
|
||||
contactId: 'ct-2',
|
||||
branchId: 'bkk',
|
||||
salesmanId: 'sp-1',
|
||||
productType: 'dockdoor',
|
||||
status: 'cancelled',
|
||||
requirementSummary: 'High-speed doors for food-grade area.',
|
||||
projectLocation: 'Pathum Thani',
|
||||
chancePercent: 0,
|
||||
competitor: 'FastDoor',
|
||||
expectedValue: 1600000,
|
||||
dueDate: '2026-06-11',
|
||||
createdAt: '2026-05-25T10:10:00.000Z',
|
||||
updatedAt: '2026-06-04T11:00:00.000Z',
|
||||
quotationIds: [],
|
||||
activities: [
|
||||
{ id: 'enq-7-act-1', type: 'UPDATE', title: 'Project cancelled', detail: 'Owner เลื่อน CAPEX', actorName: 'Nicha P.', createdAt: '2026-06-04T11:00:00.000Z' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'enq-8',
|
||||
code: 'ENQ2606-008',
|
||||
title: 'Monorail crane for packaging line',
|
||||
customerId: 'cus-3',
|
||||
contactId: 'ct-6',
|
||||
branchId: 'chn',
|
||||
salesmanId: 'sp-3',
|
||||
productType: 'crane',
|
||||
status: 'new',
|
||||
requirementSummary: '1 ton monorail crane with quick delivery.',
|
||||
projectLocation: 'Lamphun Plant',
|
||||
chancePercent: 28,
|
||||
competitor: 'NorthHoist',
|
||||
expectedValue: 740000,
|
||||
dueDate: '2026-06-24',
|
||||
createdAt: '2026-06-09T13:00:00.000Z',
|
||||
updatedAt: '2026-06-09T13:00:00.000Z',
|
||||
quotationIds: [],
|
||||
activities: [
|
||||
{ id: 'enq-8-act-1', type: 'CALL', title: 'รับ enquiry ใหม่', detail: 'ต้องการเสนอราคาใน 7 วัน', actorName: 'Ton A.', createdAt: '2026-06-09T13:00:00.000Z' }
|
||||
]
|
||||
}
|
||||
],
|
||||
quotations: [
|
||||
{
|
||||
id: 'qt-1',
|
||||
code: 'QT2606-001',
|
||||
enquiryId: 'enq-1',
|
||||
quotationDate: '2026-06-02',
|
||||
validUntil: '2026-07-02',
|
||||
quotationType: 'official',
|
||||
project: 'Warehouse Dock Modernization',
|
||||
siteLocation: 'Bangkok Logistics Park',
|
||||
customerRoles: [
|
||||
{ role: 'owner', customerId: 'cus-1', contactId: 'ct-1' },
|
||||
{ role: 'consultant', customerId: 'cus-4', contactId: 'ct-7' },
|
||||
{ role: 'billing', customerId: 'cus-2', contactId: 'ct-4' }
|
||||
],
|
||||
salesmanId: 'sp-1',
|
||||
saleAdminId: 'sp-2',
|
||||
branchId: 'bkk',
|
||||
status: 'pending_approval',
|
||||
revision: 0,
|
||||
totalAmount: 2750000,
|
||||
chancePercent: 82,
|
||||
isHotProject: true,
|
||||
approvalSnapshot: 'Awaiting Level 2 approval',
|
||||
items: [
|
||||
{ id: 'qt-1-item-1', topic: 'Mechanical', description: 'Hydraulic dock leveler 8 sets', quantity: 8, unit: 'set', unitPrice: 280000, amount: 2240000 },
|
||||
{ id: 'qt-1-item-2', topic: 'Installation', description: 'Site install and commissioning', quantity: 1, unit: 'lot', unitPrice: 510000, amount: 510000 }
|
||||
],
|
||||
topics: [
|
||||
{ id: 'qt-1-topic-1', type: 'scope', label: 'Scope', items: ['Supply equipment', 'Install and test', 'Operator training'] },
|
||||
{ id: 'qt-1-topic-2', type: 'exclusion', label: 'Exclusion', items: ['Civil work by customer', 'Night shift overtime'] },
|
||||
{ id: 'qt-1-topic-3', type: 'payment', label: 'Payment', items: ['40% down payment', '50% upon delivery', '10% after handover'] }
|
||||
],
|
||||
followUps: [
|
||||
{ id: 'qt-1-fu-1', title: 'Prepare approval summary', dueDate: '2026-06-12', ownerName: 'Nicha P.', status: 'open' },
|
||||
{ id: 'qt-1-fu-2', title: 'Confirm shutdown window', dueDate: '2026-06-13', ownerName: 'Krit S.', status: 'open' }
|
||||
],
|
||||
attachments: [
|
||||
{ id: 'qt-1-att-1', fileName: 'technical-spec.pdf', fileType: 'pdf', uploadedAt: '2026-06-02T10:00:00.000Z' }
|
||||
],
|
||||
approvalSteps: [
|
||||
{ id: 'qt-1-ap-1', level: 1, approverName: 'Head of Sales', approverPosition: 'Sales Director', status: 'approved', actedAt: '2026-06-03T09:30:00.000Z' },
|
||||
{ id: 'qt-1-ap-2', level: 2, approverName: 'CEO Office', approverPosition: 'Managing Director', status: 'pending' }
|
||||
],
|
||||
auditEventIds: ['audit-1', 'audit-2']
|
||||
},
|
||||
{
|
||||
id: 'qt-2',
|
||||
code: 'QT2606-001-R01',
|
||||
enquiryId: 'enq-1',
|
||||
quotationDate: '2026-06-07',
|
||||
validUntil: '2026-07-07',
|
||||
quotationType: 'official',
|
||||
project: 'Warehouse Dock Modernization',
|
||||
siteLocation: 'Bangkok Logistics Park',
|
||||
customerRoles: [
|
||||
{ role: 'owner', customerId: 'cus-1', contactId: 'ct-1' },
|
||||
{ role: 'consultant', customerId: 'cus-4', contactId: 'ct-7' }
|
||||
],
|
||||
salesmanId: 'sp-1',
|
||||
saleAdminId: 'sp-2',
|
||||
branchId: 'bkk',
|
||||
status: 'revised',
|
||||
revision: 1,
|
||||
totalAmount: 2875000,
|
||||
chancePercent: 78,
|
||||
isHotProject: true,
|
||||
approvedPdfUrl: '/mock/qt2606-001-r01.pdf',
|
||||
items: [
|
||||
{ id: 'qt-2-item-1', topic: 'Mechanical', description: 'Hydraulic dock leveler 8 sets', quantity: 8, unit: 'set', unitPrice: 280000, amount: 2240000 },
|
||||
{ id: 'qt-2-item-2', topic: 'Safety', description: 'Additional dock light and safety package', quantity: 1, unit: 'lot', unitPrice: 635000, amount: 635000 }
|
||||
],
|
||||
topics: [
|
||||
{ id: 'qt-2-topic-1', type: 'scope', label: 'Scope', items: ['Updated safety package', 'Delivery within 30 days'] },
|
||||
{ id: 'qt-2-topic-2', type: 'exclusion', label: 'Exclusion', items: ['Permit by customer'] },
|
||||
{ id: 'qt-2-topic-3', type: 'payment', label: 'Payment', items: ['40/50/10 milestone'] }
|
||||
],
|
||||
followUps: [
|
||||
{ id: 'qt-2-fu-1', title: 'Send revised file to customer', dueDate: '2026-06-12', ownerName: 'Krit S.', status: 'open' }
|
||||
],
|
||||
attachments: [
|
||||
{ id: 'qt-2-att-1', fileName: 'rev1-comparison.pdf', fileType: 'pdf', uploadedAt: '2026-06-07T16:00:00.000Z' }
|
||||
],
|
||||
approvalSteps: [
|
||||
{ id: 'qt-2-ap-1', level: 1, approverName: 'Head of Sales', approverPosition: 'Sales Director', status: 'approved', actedAt: '2026-06-07T15:30:00.000Z' },
|
||||
{ id: 'qt-2-ap-2', level: 2, approverName: 'CEO Office', approverPosition: 'Managing Director', status: 'approved', actedAt: '2026-06-08T09:00:00.000Z' }
|
||||
],
|
||||
auditEventIds: ['audit-3']
|
||||
},
|
||||
{
|
||||
id: 'qt-3',
|
||||
code: 'QT2606-002',
|
||||
enquiryId: 'enq-2',
|
||||
quotationDate: '2026-06-05',
|
||||
validUntil: '2026-07-05',
|
||||
quotationType: 'budgetary',
|
||||
project: 'Fabrication Crane Upgrade',
|
||||
siteLocation: 'Samut Sakhon Plant',
|
||||
customerRoles: [
|
||||
{ role: 'owner', customerId: 'cus-2', contactId: 'ct-3' },
|
||||
{ role: 'contractor', customerId: 'cus-4', contactId: 'ct-8' }
|
||||
],
|
||||
salesmanId: 'sp-1',
|
||||
saleAdminId: 'sp-2',
|
||||
branchId: 'bkk',
|
||||
status: 'draft',
|
||||
revision: 0,
|
||||
totalAmount: 5200000,
|
||||
chancePercent: 68,
|
||||
isHotProject: false,
|
||||
items: [
|
||||
{ id: 'qt-3-item-1', topic: 'Crane', description: '10 ton overhead crane', quantity: 1, unit: 'set', unitPrice: 4500000, amount: 4500000 },
|
||||
{ id: 'qt-3-item-2', topic: 'IoT', description: 'Remote monitoring package', quantity: 1, unit: 'set', unitPrice: 700000, amount: 700000 }
|
||||
],
|
||||
topics: [
|
||||
{ id: 'qt-3-topic-1', type: 'scope', label: 'Scope', items: ['Supply', 'Install', 'Commission'] },
|
||||
{ id: 'qt-3-topic-2', type: 'payment', label: 'Payment', items: ['50% deposit', '40% delivery', '10% handover'] },
|
||||
{ id: 'qt-3-topic-3', type: 'exclusion', label: 'Exclusion', items: ['Building strengthening'] }
|
||||
],
|
||||
followUps: [{ id: 'qt-3-fu-1', title: 'Finalize civil scope', dueDate: '2026-06-15', ownerName: 'Krit S.', status: 'open' }],
|
||||
attachments: [],
|
||||
approvalSteps: [],
|
||||
auditEventIds: []
|
||||
},
|
||||
{
|
||||
id: 'qt-4',
|
||||
code: 'QT2606-003',
|
||||
enquiryId: 'enq-3',
|
||||
quotationDate: '2026-06-03',
|
||||
validUntil: '2026-07-03',
|
||||
quotationType: 'official',
|
||||
project: 'Cold Chain Rooftop Solar',
|
||||
siteLocation: 'Chiang Mai DC',
|
||||
customerRoles: [
|
||||
{ role: 'owner', customerId: 'cus-3', contactId: 'ct-5' },
|
||||
{ role: 'consultant', customerId: 'cus-5', contactId: 'ct-9' }
|
||||
],
|
||||
salesmanId: 'sp-3',
|
||||
saleAdminId: 'sp-2',
|
||||
branchId: 'chn',
|
||||
status: 'sent',
|
||||
revision: 0,
|
||||
totalAmount: 6100000,
|
||||
chancePercent: 49,
|
||||
isHotProject: true,
|
||||
approvedPdfUrl: '/mock/qt2606-003.pdf',
|
||||
items: [
|
||||
{ id: 'qt-4-item-1', topic: 'Solar', description: '500 kWp rooftop system', quantity: 1, unit: 'lot', unitPrice: 5900000, amount: 5900000 },
|
||||
{ id: 'qt-4-item-2', topic: 'Monitoring', description: 'EMS dashboard', quantity: 1, unit: 'lot', unitPrice: 200000, amount: 200000 }
|
||||
],
|
||||
topics: [
|
||||
{ id: 'qt-4-topic-1', type: 'scope', label: 'Scope', items: ['EPC turnkey'] },
|
||||
{ id: 'qt-4-topic-2', type: 'payment', label: 'Payment', items: ['30/60/10'] },
|
||||
{ id: 'qt-4-topic-3', type: 'exclusion', label: 'Exclusion', items: ['Transformer upgrade'] }
|
||||
],
|
||||
followUps: [{ id: 'qt-4-fu-1', title: 'Follow up board decision', dueDate: '2026-06-11', ownerName: 'Ton A.', status: 'open' }],
|
||||
attachments: [{ id: 'qt-4-att-1', fileName: 'financial-model.xls', fileType: 'xls', uploadedAt: '2026-06-03T12:00:00.000Z' }],
|
||||
approvalSteps: [
|
||||
{ id: 'qt-4-ap-1', level: 1, approverName: 'Regional Sales Manager', approverPosition: 'Regional Manager', status: 'approved', actedAt: '2026-06-03T11:00:00.000Z' }
|
||||
],
|
||||
auditEventIds: ['audit-4']
|
||||
},
|
||||
{
|
||||
id: 'qt-5',
|
||||
code: 'QT2606-004',
|
||||
enquiryId: 'enq-5',
|
||||
quotationDate: '2026-06-06',
|
||||
validUntil: '2026-07-06',
|
||||
quotationType: 'service',
|
||||
project: 'Annual Crane PM',
|
||||
siteLocation: 'Ayutthaya Plant',
|
||||
customerRoles: [{ role: 'owner', customerId: 'cus-2', contactId: 'ct-4' }],
|
||||
salesmanId: 'sp-1',
|
||||
saleAdminId: 'sp-2',
|
||||
branchId: 'bkk',
|
||||
status: 'approved',
|
||||
revision: 0,
|
||||
totalAmount: 980000,
|
||||
chancePercent: 53,
|
||||
isHotProject: false,
|
||||
approvedPdfUrl: '/mock/qt2606-004.pdf',
|
||||
items: [{ id: 'qt-5-item-1', topic: 'Service', description: 'PM contract 12 months', quantity: 1, unit: 'lot', unitPrice: 980000, amount: 980000 }],
|
||||
topics: [
|
||||
{ id: 'qt-5-topic-1', type: 'scope', label: 'Scope', items: ['Quarterly PM', 'Emergency call support'] },
|
||||
{ id: 'qt-5-topic-2', type: 'payment', label: 'Payment', items: ['100% after PO'] },
|
||||
{ id: 'qt-5-topic-3', type: 'exclusion', label: 'Exclusion', items: ['Spare parts'] }
|
||||
],
|
||||
followUps: [{ id: 'qt-5-fu-1', title: 'Await PO', dueDate: '2026-06-17', ownerName: 'Nicha P.', status: 'open' }],
|
||||
attachments: [],
|
||||
approvalSteps: [{ id: 'qt-5-ap-1', level: 1, approverName: 'Service GM', approverPosition: 'GM Service', status: 'approved', actedAt: '2026-06-07T10:00:00.000Z' }],
|
||||
auditEventIds: []
|
||||
},
|
||||
{
|
||||
id: 'qt-6',
|
||||
code: 'QT2606-005',
|
||||
enquiryId: 'enq-6',
|
||||
quotationDate: '2026-05-26',
|
||||
validUntil: '2026-06-25',
|
||||
quotationType: 'budgetary',
|
||||
project: 'Solar Canopy Feasibility',
|
||||
siteLocation: 'Chiang Rai Service Center',
|
||||
customerRoles: [{ role: 'owner', customerId: 'cus-5', contactId: 'ct-9' }],
|
||||
salesmanId: 'sp-3',
|
||||
saleAdminId: 'sp-2',
|
||||
branchId: 'chn',
|
||||
status: 'lost',
|
||||
revision: 0,
|
||||
totalAmount: 2400000,
|
||||
chancePercent: 0,
|
||||
isHotProject: false,
|
||||
items: [{ id: 'qt-6-item-1', topic: 'Solar', description: 'Canopy concept and EPC budget', quantity: 1, unit: 'lot', unitPrice: 2400000, amount: 2400000 }],
|
||||
topics: [
|
||||
{ id: 'qt-6-topic-1', type: 'scope', label: 'Scope', items: ['Concept study'] },
|
||||
{ id: 'qt-6-topic-2', type: 'payment', label: 'Payment', items: ['50/50'] },
|
||||
{ id: 'qt-6-topic-3', type: 'exclusion', label: 'Exclusion', items: ['Authority permit'] }
|
||||
],
|
||||
followUps: [],
|
||||
attachments: [],
|
||||
approvalSteps: [],
|
||||
auditEventIds: ['audit-5']
|
||||
},
|
||||
{
|
||||
id: 'qt-7',
|
||||
code: 'QT2606-006',
|
||||
enquiryId: 'enq-4',
|
||||
quotationDate: '2026-06-09',
|
||||
validUntil: '2026-07-09',
|
||||
quotationType: 'official',
|
||||
project: 'Dock Shelter Retrofit',
|
||||
siteLocation: 'Bangna Logistics Hub',
|
||||
customerRoles: [{ role: 'consultant', customerId: 'cus-4', contactId: 'ct-7' }],
|
||||
salesmanId: 'sp-1',
|
||||
saleAdminId: 'sp-2',
|
||||
branchId: 'bkk',
|
||||
status: 'pending_approval',
|
||||
revision: 0,
|
||||
totalAmount: 1850000,
|
||||
chancePercent: 35,
|
||||
isHotProject: false,
|
||||
items: [{ id: 'qt-7-item-1', topic: 'Retrofit', description: 'Dock shelter 12 bays', quantity: 12, unit: 'bay', unitPrice: 154166.67, amount: 1850000 }],
|
||||
topics: [
|
||||
{ id: 'qt-7-topic-1', type: 'scope', label: 'Scope', items: ['Supply and install 12 shelters'] },
|
||||
{ id: 'qt-7-topic-2', type: 'payment', label: 'Payment', items: ['50/50'] },
|
||||
{ id: 'qt-7-topic-3', type: 'exclusion', label: 'Exclusion', items: ['Scaffold by others'] }
|
||||
],
|
||||
followUps: [{ id: 'qt-7-fu-1', title: 'Submit approval', dueDate: '2026-06-12', ownerName: 'Nicha P.', status: 'open' }],
|
||||
attachments: [],
|
||||
approvalSteps: [{ id: 'qt-7-ap-1', level: 1, approverName: 'Sales Director', approverPosition: 'Director', status: 'pending' }],
|
||||
auditEventIds: []
|
||||
},
|
||||
{
|
||||
id: 'qt-8',
|
||||
code: 'QT2606-007',
|
||||
enquiryId: 'enq-8',
|
||||
quotationDate: '2026-06-10',
|
||||
validUntil: '2026-07-10',
|
||||
quotationType: 'official',
|
||||
project: 'Monorail Crane Fast Track',
|
||||
siteLocation: 'Lamphun Plant',
|
||||
customerRoles: [{ role: 'owner', customerId: 'cus-3', contactId: 'ct-6' }],
|
||||
salesmanId: 'sp-3',
|
||||
saleAdminId: 'sp-2',
|
||||
branchId: 'chn',
|
||||
status: 'draft',
|
||||
revision: 0,
|
||||
totalAmount: 740000,
|
||||
chancePercent: 28,
|
||||
isHotProject: false,
|
||||
items: [{ id: 'qt-8-item-1', topic: 'Crane', description: '1 ton monorail crane', quantity: 1, unit: 'set', unitPrice: 740000, amount: 740000 }],
|
||||
topics: [
|
||||
{ id: 'qt-8-topic-1', type: 'scope', label: 'Scope', items: ['Supply only'] },
|
||||
{ id: 'qt-8-topic-2', type: 'payment', label: 'Payment', items: ['100% before delivery'] },
|
||||
{ id: 'qt-8-topic-3', type: 'exclusion', label: 'Exclusion', items: ['Install by customer'] }
|
||||
],
|
||||
followUps: [],
|
||||
attachments: [],
|
||||
approvalSteps: [],
|
||||
auditEventIds: []
|
||||
},
|
||||
{
|
||||
id: 'qt-9',
|
||||
code: 'QT2606-008',
|
||||
enquiryId: 'enq-2',
|
||||
quotationDate: '2026-06-08',
|
||||
validUntil: '2026-07-08',
|
||||
quotationType: 'official',
|
||||
project: 'Crane Upgrade Option B',
|
||||
siteLocation: 'Samut Sakhon Plant',
|
||||
customerRoles: [{ role: 'owner', customerId: 'cus-2', contactId: 'ct-3' }],
|
||||
salesmanId: 'sp-1',
|
||||
saleAdminId: 'sp-2',
|
||||
branchId: 'bkk',
|
||||
status: 'rejected',
|
||||
revision: 0,
|
||||
totalAmount: 5450000,
|
||||
chancePercent: 22,
|
||||
isHotProject: false,
|
||||
items: [{ id: 'qt-9-item-1', topic: 'Crane', description: 'Higher spec option', quantity: 1, unit: 'set', unitPrice: 5450000, amount: 5450000 }],
|
||||
topics: [
|
||||
{ id: 'qt-9-topic-1', type: 'scope', label: 'Scope', items: ['Higher duty cycle spec'] },
|
||||
{ id: 'qt-9-topic-2', type: 'payment', label: 'Payment', items: ['40/50/10'] },
|
||||
{ id: 'qt-9-topic-3', type: 'exclusion', label: 'Exclusion', items: ['Building modification'] }
|
||||
],
|
||||
followUps: [],
|
||||
attachments: [],
|
||||
approvalSteps: [{ id: 'qt-9-ap-1', level: 1, approverName: 'Sales Director', approverPosition: 'Director', status: 'rejected', actedAt: '2026-06-09T13:00:00.000Z', comment: 'Margin below target' }],
|
||||
auditEventIds: []
|
||||
},
|
||||
{
|
||||
id: 'qt-10',
|
||||
code: 'QT2606-009',
|
||||
enquiryId: 'enq-5',
|
||||
quotationDate: '2026-06-10',
|
||||
validUntil: '2026-07-10',
|
||||
quotationType: 'service',
|
||||
project: 'Spare Parts Bundle',
|
||||
siteLocation: 'Ayutthaya Plant',
|
||||
customerRoles: [{ role: 'billing', customerId: 'cus-2', contactId: 'ct-4' }],
|
||||
salesmanId: 'sp-1',
|
||||
saleAdminId: 'sp-2',
|
||||
branchId: 'bkk',
|
||||
status: 'accepted',
|
||||
revision: 0,
|
||||
totalAmount: 420000,
|
||||
chancePercent: 100,
|
||||
isHotProject: false,
|
||||
approvedPdfUrl: '/mock/qt2606-009.pdf',
|
||||
items: [{ id: 'qt-10-item-1', topic: 'Spare Parts', description: 'Critical spare bundle', quantity: 1, unit: 'lot', unitPrice: 420000, amount: 420000 }],
|
||||
topics: [
|
||||
{ id: 'qt-10-topic-1', type: 'scope', label: 'Scope', items: ['Spare bundle supply'] },
|
||||
{ id: 'qt-10-topic-2', type: 'payment', label: 'Payment', items: ['100% with PO'] },
|
||||
{ id: 'qt-10-topic-3', type: 'exclusion', label: 'Exclusion', items: ['On-site labor'] }
|
||||
],
|
||||
followUps: [{ id: 'qt-10-fu-1', title: 'Coordinate delivery slot', dueDate: '2026-06-16', ownerName: 'Nicha P.', status: 'done' }],
|
||||
attachments: [{ id: 'qt-10-att-1', fileName: 'spare-list.doc', fileType: 'doc', uploadedAt: '2026-06-10T09:00:00.000Z' }],
|
||||
approvalSteps: [{ id: 'qt-10-ap-1', level: 1, approverName: 'Service GM', approverPosition: 'GM Service', status: 'approved', actedAt: '2026-06-10T08:00:00.000Z' }],
|
||||
auditEventIds: []
|
||||
}
|
||||
],
|
||||
approvals: [
|
||||
{ id: 'approval-1', quotationId: 'qt-1', quotationCode: 'QT2606-001', amount: 2750000, productType: 'dockdoor', branchId: 'bkk', submitterName: 'Nicha P.', approverLevel: 2, status: 'pending' },
|
||||
{ id: 'approval-2', quotationId: 'qt-7', quotationCode: 'QT2606-006', amount: 1850000, productType: 'dockdoor', branchId: 'bkk', submitterName: 'Nicha P.', approverLevel: 1, status: 'pending' },
|
||||
{ id: 'approval-3', quotationId: 'qt-3', quotationCode: 'QT2606-002', amount: 5200000, productType: 'crane', branchId: 'bkk', submitterName: 'Krit S.', approverLevel: 1, status: 'pending' }
|
||||
],
|
||||
auditEvents: [
|
||||
{ id: 'audit-1', entityType: 'quotation', entityId: 'qt-1', action: 'CREATE', actorName: 'Nicha P.', detail: 'สร้างใบเสนอราคา QT2606-001', createdAt: '2026-06-02T08:15:00.000Z' },
|
||||
{ id: 'audit-2', entityType: 'approval', entityId: 'qt-1', action: 'APPROVE', actorName: 'Head of Sales', detail: 'อนุมัติ level 1', createdAt: '2026-06-03T09:30:00.000Z' },
|
||||
{ id: 'audit-3', entityType: 'quotation', entityId: 'qt-2', action: 'REVISE', actorName: 'Krit S.', detail: 'สร้าง revision R01 เพิ่ม safety package', createdAt: '2026-06-07T16:00:00.000Z' },
|
||||
{ id: 'audit-4', entityType: 'quotation', entityId: 'qt-4', action: 'SEND', actorName: 'Ton A.', detail: 'ส่งใบเสนอราคาให้ลูกค้าทางอีเมล', createdAt: '2026-06-04T10:10:00.000Z' },
|
||||
{ id: 'audit-5', entityType: 'quotation', entityId: 'qt-6', action: 'REJECT', actorName: 'Customer Board', detail: 'ลูกค้าเลือกคู่แข่งเนื่องจากราคา', createdAt: '2026-06-08T18:00:00.000Z' }
|
||||
],
|
||||
sequences: [
|
||||
{ id: 'seq-1', documentType: 'enquiry', prefix: 'ENQ', period: '2606', branchId: 'bkk', currentNumber: 8, paddingLength: 3 },
|
||||
{ id: 'seq-2', documentType: 'quotation', prefix: 'QT', period: '2606', branchId: 'bkk', currentNumber: 9, paddingLength: 3 },
|
||||
{ id: 'seq-3', documentType: 'quotation_revision', prefix: 'QT', period: '2606', branchId: 'bkk', currentNumber: 1, paddingLength: 2 }
|
||||
],
|
||||
masterOptions: [
|
||||
{ id: 'mo-1', group: 'status', code: 'new', label: 'New', active: true },
|
||||
{ id: 'mo-2', group: 'status', code: 'pending_approval', label: 'Pending Approval', active: true },
|
||||
{ id: 'mo-3', group: 'product_type', code: 'crane', label: 'Crane', active: true },
|
||||
{ id: 'mo-4', group: 'product_type', code: 'dockdoor', label: 'Dock Door', active: true },
|
||||
{ id: 'mo-5', group: 'product_type', code: 'solarcell', label: 'Solar Cell', active: true },
|
||||
{ id: 'mo-6', group: 'payment_term', code: '40_50_10', label: '40/50/10', active: true },
|
||||
{ id: 'mo-7', group: 'currency', code: 'THB', label: 'Thai Baht', active: true },
|
||||
{ id: 'mo-8', group: 'tax_rate', code: 'VAT7', label: 'VAT 7%', active: true }
|
||||
],
|
||||
templates: [
|
||||
{
|
||||
id: 'tpl-1',
|
||||
name: 'Standard Commercial Proposal',
|
||||
version: 'v1.2',
|
||||
branchId: 'bkk',
|
||||
description: 'Template for standard equipment quotation.',
|
||||
mappings: [
|
||||
{ id: 'tpl-1-map-1', key: 'customer_name', placeholder: '{{customer_name}}', sourceField: 'customer.name' },
|
||||
{ id: 'tpl-1-map-2', key: 'project_name', placeholder: '{{project_name}}', sourceField: 'quotation.project' },
|
||||
{ id: 'tpl-1-map-3', key: 'quotation_price', placeholder: '{{quotation_price}}', sourceField: 'quotation.totalAmount' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'tpl-2',
|
||||
name: 'Solar EPC Proposal',
|
||||
version: 'v0.9',
|
||||
branchId: 'chn',
|
||||
description: 'Template for solar EPC quotation preview.',
|
||||
mappings: [
|
||||
{ id: 'tpl-2-map-1', key: 'site_location', placeholder: '{{site_location}}', sourceField: 'quotation.siteLocation' },
|
||||
{ id: 'tpl-2-map-2', key: 'approver_names', placeholder: '{{approver_names}}', sourceField: 'quotation.approvalSteps[].approverName' }
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
39
src/features/crm/data/options.ts
Normal file
39
src/features/crm/data/options.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
export const enquiryStatusOptions = [
|
||||
{ value: 'new', label: 'New' },
|
||||
{ value: 'qualifying', label: 'Qualifying' },
|
||||
{ value: 'requirement', label: 'Requirement' },
|
||||
{ value: 'follow_up', label: 'Follow Up' },
|
||||
{ value: 'converted', label: 'Converted' },
|
||||
{ value: 'closed_lost', label: 'Closed Lost' },
|
||||
{ value: 'cancelled', label: 'Cancelled' }
|
||||
] as const;
|
||||
|
||||
export const quotationStatusOptions = [
|
||||
{ value: 'draft', label: 'Draft' },
|
||||
{ value: 'pending_approval', label: 'Pending Approval' },
|
||||
{ value: 'approved', label: 'Approved' },
|
||||
{ value: 'rejected', label: 'Rejected' },
|
||||
{ value: 'sent', label: 'Sent' },
|
||||
{ value: 'accepted', label: 'Accepted' },
|
||||
{ value: 'lost', label: 'Lost' },
|
||||
{ value: 'cancelled', label: 'Cancelled' },
|
||||
{ value: 'revised', label: 'Revised' }
|
||||
] as const;
|
||||
|
||||
export const customerStatusOptions = [
|
||||
{ value: 'active', label: 'Active' },
|
||||
{ value: 'prospect', label: 'Prospect' },
|
||||
{ value: 'inactive', label: 'Inactive' }
|
||||
] as const;
|
||||
|
||||
export const productTypeOptions = [
|
||||
{ value: 'crane', label: 'Crane' },
|
||||
{ value: 'dockdoor', label: 'Dock Door' },
|
||||
{ value: 'solarcell', label: 'Solar Cell' }
|
||||
] as const;
|
||||
|
||||
export const quotationTypeOptions = [
|
||||
{ value: 'official', label: 'Official' },
|
||||
{ value: 'budgetary', label: 'Budgetary' },
|
||||
{ value: 'service', label: 'Service' }
|
||||
] as const;
|
||||
19
src/features/crm/schemas/customer.ts
Normal file
19
src/features/crm/schemas/customer.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const customerSchema = z.object({
|
||||
code: z.string().min(3),
|
||||
name: z.string().min(3),
|
||||
taxId: z.string().min(8),
|
||||
email: z.string().email(),
|
||||
phone: z.string().min(8),
|
||||
customerType: z.enum(['developer', 'contractor', 'owner', 'consultant']),
|
||||
customerStatus: z.enum(['active', 'prospect', 'inactive']),
|
||||
branchId: z.string().min(1),
|
||||
address: z.string().min(3),
|
||||
province: z.string().min(2),
|
||||
district: z.string().min(2),
|
||||
subDistrict: z.string().min(2),
|
||||
postalCode: z.string().min(4)
|
||||
});
|
||||
|
||||
export type CustomerFormValues = z.infer<typeof customerSchema>;
|
||||
19
src/features/crm/utils/format.ts
Normal file
19
src/features/crm/utils/format.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export function formatCurrency(value: number) {
|
||||
return new Intl.NumberFormat('th-TH', {
|
||||
style: 'currency',
|
||||
currency: 'THB',
|
||||
maximumFractionDigits: 0
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
export function formatDate(value: string) {
|
||||
return new Intl.DateTimeFormat('th-TH', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric'
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
export function formatPercent(value: number) {
|
||||
return `${value}%`;
|
||||
}
|
||||
41
src/features/crm/utils/status.ts
Normal file
41
src/features/crm/utils/status.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { Customer, EnquiryStatus, QuotationStatus } from '../api/types';
|
||||
|
||||
export function getCustomerStatusLabel(status: Customer['customerStatus']) {
|
||||
return (
|
||||
{
|
||||
active: 'Active',
|
||||
prospect: 'Prospect',
|
||||
inactive: 'Inactive'
|
||||
}[status] ?? status
|
||||
);
|
||||
}
|
||||
|
||||
export function getEnquiryStatusLabel(status: EnquiryStatus) {
|
||||
return (
|
||||
{
|
||||
new: 'New',
|
||||
qualifying: 'Qualifying',
|
||||
requirement: 'Requirement',
|
||||
follow_up: 'Follow Up',
|
||||
converted: 'Converted',
|
||||
closed_lost: 'Closed Lost',
|
||||
cancelled: 'Cancelled'
|
||||
}[status] ?? status
|
||||
);
|
||||
}
|
||||
|
||||
export function getQuotationStatusLabel(status: QuotationStatus) {
|
||||
return (
|
||||
{
|
||||
draft: 'Draft',
|
||||
pending_approval: 'Pending Approval',
|
||||
approved: 'Approved',
|
||||
rejected: 'Rejected',
|
||||
sent: 'Sent',
|
||||
accepted: 'Accepted',
|
||||
lost: 'Lost',
|
||||
cancelled: 'Cancelled',
|
||||
revised: 'Revised'
|
||||
}[status] ?? status
|
||||
);
|
||||
}
|
||||
60
src/features/elements/components/icons-view-page.tsx
Normal file
60
src/features/elements/components/icons-view-page.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Icons } from '@/components/icons';
|
||||
import PageContainer from '@/components/layout/page-container';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { buttonVariants } from '@/components/ui/button';
|
||||
import Link from 'next/link';
|
||||
|
||||
const TABLER_ICONS_URL = 'https://tabler.io/icons';
|
||||
|
||||
export default function IconsViewPage() {
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const iconEntries = Object.entries(Icons).filter(([name]) =>
|
||||
name.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
pageTitle='Icons'
|
||||
pageHeaderAction={
|
||||
<Link
|
||||
href={TABLER_ICONS_URL}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className={buttonVariants({ variant: 'outline' })}
|
||||
>
|
||||
<Icons.externalLink className='mr-2 h-4 w-4' />
|
||||
<span className='hidden sm:inline'>Browse</span> Tabler Icons
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
<div className='space-y-4'>
|
||||
<Input
|
||||
placeholder='Search icons...'
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className='max-w-sm'
|
||||
/>
|
||||
<div className='grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8'>
|
||||
{iconEntries.map(([name, IconComponent]) => (
|
||||
<div
|
||||
key={name}
|
||||
className='hover:bg-accent flex flex-col items-center gap-2 rounded-lg border p-4 text-center transition-colors'
|
||||
>
|
||||
<IconComponent className='h-6 w-6' />
|
||||
<span className='text-muted-foreground text-xs break-all'>{name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{iconEntries.length === 0 && (
|
||||
<p className='text-muted-foreground py-8 text-center'>
|
||||
No icons found matching "{search}"
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
51
src/features/example-dashboard/api.ts
Normal file
51
src/features/example-dashboard/api.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { queryOptions } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import type { ProductFilters, ProductsResponse } from '@/features/products/api/types';
|
||||
import type { UserFilters, UsersResponse } from '@/features/users/api/types';
|
||||
|
||||
async function getExampleProducts(filters: ProductFilters): Promise<ProductsResponse> {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
if (filters.page) searchParams.set('page', String(filters.page));
|
||||
if (filters.limit) searchParams.set('limit', String(filters.limit));
|
||||
if (filters.categories) searchParams.set('categories', filters.categories);
|
||||
if (filters.search) searchParams.set('search', filters.search);
|
||||
if (filters.sort) searchParams.set('sort', filters.sort);
|
||||
|
||||
return apiClient<ProductsResponse>(`/example/products?${searchParams.toString()}`);
|
||||
}
|
||||
|
||||
async function getExampleUsers(filters: UserFilters): Promise<UsersResponse> {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
if (filters.page) searchParams.set('page', String(filters.page));
|
||||
if (filters.limit) searchParams.set('limit', String(filters.limit));
|
||||
if (filters.roles) searchParams.set('roles', filters.roles);
|
||||
if (filters.search) searchParams.set('search', filters.search);
|
||||
if (filters.sort) searchParams.set('sort', filters.sort);
|
||||
|
||||
const query = searchParams.toString();
|
||||
return apiClient<UsersResponse>(`/example/users${query ? `?${query}` : ''}`);
|
||||
}
|
||||
|
||||
export const exampleProductKeys = {
|
||||
all: ['example-products'] as const,
|
||||
list: (filters: ProductFilters) => [...exampleProductKeys.all, 'list', filters] as const
|
||||
};
|
||||
|
||||
export const exampleUserKeys = {
|
||||
all: ['example-users'] as const,
|
||||
list: (filters: UserFilters) => [...exampleUserKeys.all, 'list', filters] as const
|
||||
};
|
||||
|
||||
export const exampleProductsQueryOptions = (filters: ProductFilters) =>
|
||||
queryOptions({
|
||||
queryKey: exampleProductKeys.list(filters),
|
||||
queryFn: () => getExampleProducts(filters)
|
||||
});
|
||||
|
||||
export const exampleUsersQueryOptions = (filters: UserFilters) =>
|
||||
queryOptions({
|
||||
queryKey: exampleUserKeys.list(filters),
|
||||
queryFn: () => getExampleUsers(filters)
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
'use client';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { DataTable } from '@/components/ui/table/data-table';
|
||||
import { DataTableColumnHeader } from '@/components/ui/table/data-table-column-header';
|
||||
import { DataTableToolbar } from '@/components/ui/table/data-table-toolbar';
|
||||
import { useDataTable } from '@/hooks/use-data-table';
|
||||
import { getSortingStateParser } from '@/lib/parsers';
|
||||
import { parseAsInteger, parseAsString, useQueryStates } from 'nuqs';
|
||||
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||
import type { Column, ColumnDef } from '@tanstack/react-table';
|
||||
import { ProductImagePreview } from '@/features/products/components/product-tables/product-image-preview';
|
||||
import type { Product } from '@/features/products/api/types';
|
||||
import { CATEGORY_OPTIONS } from '@/features/products/components/product-tables/options';
|
||||
import { Icons } from '@/components/icons';
|
||||
import { exampleProductsQueryOptions } from '@/features/example-dashboard/api';
|
||||
|
||||
const columns: ColumnDef<Product>[] = [
|
||||
{
|
||||
accessorKey: 'photo_url',
|
||||
header: 'IMAGE',
|
||||
cell: ({ row }) => (
|
||||
<ProductImagePreview src={row.getValue('photo_url')} alt={row.getValue('name')} />
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'name',
|
||||
accessorKey: 'name',
|
||||
header: ({ column }: { column: Column<Product, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Name' />
|
||||
),
|
||||
cell: ({ cell }) => <div className='font-medium'>{cell.getValue<Product['name']>()}</div>,
|
||||
meta: {
|
||||
label: 'Name',
|
||||
placeholder: 'Search products...',
|
||||
variant: 'text',
|
||||
icon: Icons.text
|
||||
},
|
||||
enableColumnFilter: true
|
||||
},
|
||||
{
|
||||
id: 'category',
|
||||
accessorKey: 'category',
|
||||
enableSorting: false,
|
||||
header: ({ column }: { column: Column<Product, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Category' />
|
||||
),
|
||||
cell: ({ cell }) => (
|
||||
<Badge variant='outline' className='capitalize'>
|
||||
{cell.getValue<Product['category']>()}
|
||||
</Badge>
|
||||
),
|
||||
enableColumnFilter: true,
|
||||
meta: {
|
||||
label: 'categories',
|
||||
variant: 'multiSelect',
|
||||
options: CATEGORY_OPTIONS
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: 'price',
|
||||
header: 'PRICE',
|
||||
cell: ({ row }) => `$${row.original.price.toLocaleString()}`
|
||||
},
|
||||
{
|
||||
accessorKey: 'description',
|
||||
header: 'DESCRIPTION'
|
||||
}
|
||||
];
|
||||
|
||||
const columnIds = columns.map((c) => c.id).filter(Boolean) as string[];
|
||||
|
||||
export function ExampleProductTable() {
|
||||
const [params] = useQueryStates({
|
||||
page: parseAsInteger.withDefault(1),
|
||||
perPage: parseAsInteger.withDefault(10),
|
||||
name: parseAsString,
|
||||
category: parseAsString,
|
||||
sort: getSortingStateParser(columnIds).withDefault([])
|
||||
});
|
||||
|
||||
const filters = {
|
||||
page: params.page,
|
||||
limit: params.perPage,
|
||||
...(params.name && { search: params.name }),
|
||||
...(params.category && { categories: params.category }),
|
||||
...(params.sort.length > 0 && { sort: JSON.stringify(params.sort) })
|
||||
};
|
||||
|
||||
const { data } = useSuspenseQuery(exampleProductsQueryOptions(filters));
|
||||
const pageCount = Math.ceil(data.total_products / params.perPage);
|
||||
|
||||
const { table } = useDataTable({
|
||||
data: data.products,
|
||||
columns,
|
||||
pageCount,
|
||||
shallow: true
|
||||
});
|
||||
|
||||
return (
|
||||
<DataTable table={table}>
|
||||
<DataTableToolbar table={table} />
|
||||
</DataTable>
|
||||
);
|
||||
}
|
||||
118
src/features/example-dashboard/components/example-shell.tsx
Normal file
118
src/features/example-dashboard/components/example-shell.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { Icons } from '@/components/icons';
|
||||
import { InfoSidebar } from '@/components/layout/info-sidebar';
|
||||
import { ThemeModeToggle } from '@/components/themes/theme-mode-toggle';
|
||||
import { ThemeSelector } from '@/components/themes/theme-selector';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { InfobarProvider } from '@/components/ui/infobar';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { exampleDashboardNavItems } from '@/features/example-dashboard/config';
|
||||
|
||||
export function ExampleDashboardShell({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<div className='min-h-screen bg-[radial-gradient(circle_at_top,_hsl(var(--primary)/0.12),_transparent_38%),linear-gradient(180deg,_hsl(var(--background)),_hsl(var(--muted)/0.28))]'>
|
||||
<div className='mx-auto flex min-h-screen max-w-[1600px] flex-col lg:flex-row'>
|
||||
<aside className='border-border/60 bg-background/75 supports-[backdrop-filter]:bg-background/70 w-full border-b backdrop-blur-xl lg:w-72 lg:border-r lg:border-b-0'>
|
||||
<div className='flex items-center justify-between px-5 py-5 lg:block'>
|
||||
<div>
|
||||
<div className='flex items-center gap-3'>
|
||||
<div className='from-primary to-primary/70 text-primary-foreground flex h-11 w-11 items-center justify-center rounded-2xl bg-gradient-to-br shadow-lg'>
|
||||
<Icons.sparkles className='h-5 w-5' />
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-sm font-medium uppercase tracking-[0.28em] text-muted-foreground'>
|
||||
Template
|
||||
</p>
|
||||
<h1 className='font-semibold'>Example Dashboard</h1>
|
||||
</div>
|
||||
</div>
|
||||
<p className='mt-3 hidden text-sm text-muted-foreground lg:block'>
|
||||
Public showcase routes for tables, charts, icons, and React Query patterns.
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant='outline' className='hidden sm:inline-flex lg:mt-4'>
|
||||
Public demo
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className='px-3 pb-4'>
|
||||
<nav className='grid gap-1'>
|
||||
{exampleDashboardNavItems.map((item) => {
|
||||
const Icon = item.icon ? Icons[item.icon] : Icons.arrowRight;
|
||||
const isActive = pathname === item.url;
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.url}
|
||||
href={item.url}
|
||||
className={cn(
|
||||
'group flex items-center gap-3 rounded-2xl px-3 py-3 text-sm transition-colors',
|
||||
isActive
|
||||
? 'bg-primary text-primary-foreground shadow-sm'
|
||||
: 'hover:bg-muted/80 text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
<Icon className='h-4 w-4' />
|
||||
<span className='font-medium'>{item.title}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<Separator className='my-4' />
|
||||
|
||||
<div className='grid gap-2'>
|
||||
<Button asChild variant='outline' className='justify-start rounded-2xl'>
|
||||
<Link href='/setup'>
|
||||
<Icons.settings className='mr-2 h-4 w-4' />
|
||||
Setup wizard
|
||||
</Link>
|
||||
</Button>
|
||||
<p className='px-1 text-xs leading-5 text-muted-foreground'>
|
||||
Start real project setup at <code>/setup</code>, then browse these pages to see the
|
||||
template patterns in motion.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<InfobarProvider defaultOpen={false}>
|
||||
<div className='flex min-h-screen flex-1 flex-col'>
|
||||
<header className='border-border/60 bg-background/75 supports-[backdrop-filter]:bg-background/70 sticky top-0 z-20 border-b px-4 backdrop-blur-xl md:px-6'>
|
||||
<div className='flex min-h-16 items-center justify-between gap-4'>
|
||||
<div>
|
||||
<p className='text-xs uppercase tracking-[0.24em] text-muted-foreground'>
|
||||
Example Routes
|
||||
</p>
|
||||
<div className='flex items-center gap-2'>
|
||||
<h2 className='text-lg font-semibold'>Interactive template preview</h2>
|
||||
<Badge variant='secondary' className='hidden sm:inline-flex'>
|
||||
No auth required
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<ThemeModeToggle />
|
||||
<div className='hidden sm:block'>
|
||||
<ThemeSelector />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div className='flex flex-1 overflow-hidden'>
|
||||
<main className='flex min-w-0 flex-1 flex-col'>{children}</main>
|
||||
<InfoSidebar side='right' />
|
||||
</div>
|
||||
</div>
|
||||
</InfobarProvider>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
113
src/features/example-dashboard/components/example-user-table.tsx
Normal file
113
src/features/example-dashboard/components/example-user-table.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
'use client';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { DataTable } from '@/components/ui/table/data-table';
|
||||
import { DataTableColumnHeader } from '@/components/ui/table/data-table-column-header';
|
||||
import { DataTableToolbar } from '@/components/ui/table/data-table-toolbar';
|
||||
import { useDataTable } from '@/hooks/use-data-table';
|
||||
import { getSortingStateParser } from '@/lib/parsers';
|
||||
import { parseAsInteger, parseAsString, useQueryStates } from 'nuqs';
|
||||
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||
import type { Column, ColumnDef } from '@tanstack/react-table';
|
||||
import type { User } from '@/features/users/api/types';
|
||||
import { ROLE_OPTIONS } from '@/features/users/components/users-table/options';
|
||||
import { Icons } from '@/components/icons';
|
||||
import { exampleUsersQueryOptions } from '@/features/example-dashboard/api';
|
||||
|
||||
const columns: ColumnDef<User>[] = [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: ({ column }: { column: Column<User, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Name' />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className='flex flex-col'>
|
||||
<span className='font-medium'>{row.original.name}</span>
|
||||
<span className='text-muted-foreground text-xs'>{row.original.email}</span>
|
||||
</div>
|
||||
),
|
||||
meta: {
|
||||
label: 'Name',
|
||||
placeholder: 'Search users...',
|
||||
variant: 'text' as const,
|
||||
icon: Icons.text
|
||||
},
|
||||
enableColumnFilter: true
|
||||
},
|
||||
{
|
||||
id: 'role',
|
||||
accessorFn: (row) =>
|
||||
row.systemRole === 'super_admin' ? 'super_admin' : row.activeMembershipRole ?? 'user',
|
||||
header: ({ column }: { column: Column<User, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Role' />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const role =
|
||||
row.original.systemRole === 'super_admin' ? 'super_admin' : row.original.activeMembershipRole;
|
||||
|
||||
return (
|
||||
<Badge variant={role === 'admin' ? 'default' : 'outline'} className='capitalize'>
|
||||
{role?.replace('_', ' ') ?? 'user'}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
enableColumnFilter: true,
|
||||
meta: {
|
||||
label: 'roles',
|
||||
variant: 'multiSelect' as const,
|
||||
options: ROLE_OPTIONS
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'organizations',
|
||||
accessorFn: (row) => row.organizations.map((organization) => organization.name).join(', '),
|
||||
header: ({ column }: { column: Column<User, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Organizations' />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className='flex flex-wrap gap-1'>
|
||||
{row.original.memberships.map((membership) => (
|
||||
<Badge key={membership.organizationId} variant='secondary'>
|
||||
{membership.organizationName} ({membership.membershipRole}, {membership.businessRole.replace('_', ' ')})
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
const columnIds = columns.map((c) => c.id).filter(Boolean) as string[];
|
||||
|
||||
export function ExampleUserTable() {
|
||||
const [params] = useQueryStates({
|
||||
page: parseAsInteger.withDefault(1),
|
||||
perPage: parseAsInteger.withDefault(10),
|
||||
name: parseAsString,
|
||||
role: parseAsString,
|
||||
sort: getSortingStateParser(columnIds).withDefault([])
|
||||
});
|
||||
|
||||
const filters = {
|
||||
page: params.page,
|
||||
limit: params.perPage,
|
||||
...(params.name && { search: params.name }),
|
||||
...(params.role && { roles: params.role }),
|
||||
...(params.sort.length > 0 && { sort: JSON.stringify(params.sort) })
|
||||
};
|
||||
|
||||
const { data } = useSuspenseQuery(exampleUsersQueryOptions(filters));
|
||||
const pageCount = Math.ceil(data.total_users / params.perPage);
|
||||
|
||||
const { table } = useDataTable({
|
||||
data: data.users,
|
||||
columns,
|
||||
pageCount,
|
||||
shallow: true
|
||||
});
|
||||
|
||||
return (
|
||||
<DataTable table={table}>
|
||||
<DataTableToolbar table={table} />
|
||||
</DataTable>
|
||||
);
|
||||
}
|
||||
29
src/features/example-dashboard/config.ts
Normal file
29
src/features/example-dashboard/config.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { NavItem } from '@/types';
|
||||
|
||||
export const exampleDashboardNavItems: NavItem[] = [
|
||||
{
|
||||
title: 'Overview',
|
||||
url: '/example/dashboard/overview',
|
||||
icon: 'dashboard'
|
||||
},
|
||||
{
|
||||
title: 'Products',
|
||||
url: '/example/dashboard/product',
|
||||
icon: 'product'
|
||||
},
|
||||
{
|
||||
title: 'Users',
|
||||
url: '/example/dashboard/users',
|
||||
icon: 'teams'
|
||||
},
|
||||
{
|
||||
title: 'React Query',
|
||||
url: '/example/dashboard/react-query',
|
||||
icon: 'code'
|
||||
},
|
||||
{
|
||||
title: 'Icons',
|
||||
url: '/example/dashboard/elements/icons',
|
||||
icon: 'palette'
|
||||
}
|
||||
];
|
||||
300
src/features/example-dashboard/data.ts
Normal file
300
src/features/example-dashboard/data.ts
Normal file
@@ -0,0 +1,300 @@
|
||||
import type { Product } from '@/features/products/api/types';
|
||||
import type { User } from '@/features/users/api/types';
|
||||
|
||||
function imageFor(name: string, accent: string) {
|
||||
return `https://placehold.co/120x120/${accent}/ffffff/png?text=${encodeURIComponent(name.slice(0, 12))}`;
|
||||
}
|
||||
|
||||
const now = '2026-06-07T00:00:00.000Z';
|
||||
|
||||
export const exampleProducts: Product[] = [
|
||||
{
|
||||
id: 1,
|
||||
organization_id: 'example-org',
|
||||
photo_url: imageFor('Asset Portal', '1b4332'),
|
||||
name: 'Asset Portal',
|
||||
description: 'Starter module showing a route-handler-backed listing with filters and pagination.',
|
||||
created_at: now,
|
||||
price: 1290,
|
||||
category: 'Electronics',
|
||||
updated_at: now
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
organization_id: 'example-org',
|
||||
photo_url: imageFor('Ops Console', '2d6a4f'),
|
||||
name: 'Ops Console',
|
||||
description: 'Admin surface for approvals, task queues, and role-sensitive workflows.',
|
||||
created_at: now,
|
||||
price: 980,
|
||||
category: 'Books',
|
||||
updated_at: now
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
organization_id: 'example-org',
|
||||
photo_url: imageFor('Workspace Kit', '40916c'),
|
||||
name: 'Workspace Kit',
|
||||
description: 'Multi-tenant UI pattern package for organization switching and scoped data.',
|
||||
created_at: now,
|
||||
price: 1420,
|
||||
category: 'Furniture',
|
||||
updated_at: now
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
organization_id: 'example-org',
|
||||
photo_url: imageFor('Sales Pulse', '52796f'),
|
||||
name: 'Sales Pulse',
|
||||
description: 'Overview widgets and charts demonstrating KPI storytelling in a dashboard shell.',
|
||||
created_at: now,
|
||||
price: 760,
|
||||
category: 'Beauty Products',
|
||||
updated_at: now
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
organization_id: 'example-org',
|
||||
photo_url: imageFor('Field Notes', '606c38'),
|
||||
name: 'Field Notes',
|
||||
description: 'Lightweight operational recordkeeping page used to showcase table composition.',
|
||||
created_at: now,
|
||||
price: 240,
|
||||
category: 'Books',
|
||||
updated_at: now
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
organization_id: 'example-org',
|
||||
photo_url: imageFor('Checkout Sync', '283618'),
|
||||
name: 'Checkout Sync',
|
||||
description: 'Feature example for queue-based updates and cache invalidation patterns.',
|
||||
created_at: now,
|
||||
price: 1120,
|
||||
category: 'Electronics',
|
||||
updated_at: now
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
organization_id: 'example-org',
|
||||
photo_url: imageFor('Catalog Lens', 'bc6c25'),
|
||||
name: 'Catalog Lens',
|
||||
description: 'Search-oriented admin view ideal for practicing server-prefetch and filtering.',
|
||||
created_at: now,
|
||||
price: 640,
|
||||
category: 'Clothing',
|
||||
updated_at: now
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
organization_id: 'example-org',
|
||||
photo_url: imageFor('Support Desk', 'dda15e'),
|
||||
name: 'Support Desk',
|
||||
description: 'Internal tooling example for queues, ownership, and operational auditability.',
|
||||
created_at: now,
|
||||
price: 880,
|
||||
category: 'Toys',
|
||||
updated_at: now
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
organization_id: 'example-org',
|
||||
photo_url: imageFor('Client Ledger', '9c6644'),
|
||||
name: 'Client Ledger',
|
||||
description: 'Demonstrates data-heavy pages where columns, sorting, and filters matter.',
|
||||
created_at: now,
|
||||
price: 1540,
|
||||
category: 'Jewelry',
|
||||
updated_at: now
|
||||
},
|
||||
{
|
||||
id: 10,
|
||||
organization_id: 'example-org',
|
||||
photo_url: imageFor('Daily Brief', '582f0e'),
|
||||
name: 'Daily Brief',
|
||||
description: 'Small, opinionated module for daily metrics and recent changes.',
|
||||
created_at: now,
|
||||
price: 430,
|
||||
category: 'Groceries',
|
||||
updated_at: now
|
||||
},
|
||||
{
|
||||
id: 11,
|
||||
organization_id: 'example-org',
|
||||
photo_url: imageFor('Warehouse Pad', '7f4f24'),
|
||||
name: 'Warehouse Pad',
|
||||
description: 'Inventory-oriented sample used to show category filtering in the template.',
|
||||
created_at: now,
|
||||
price: 1180,
|
||||
category: 'Furniture',
|
||||
updated_at: now
|
||||
},
|
||||
{
|
||||
id: 12,
|
||||
organization_id: 'example-org',
|
||||
photo_url: imageFor('Pulse Board', '6f1d1b'),
|
||||
name: 'Pulse Board',
|
||||
description: 'A concise management view for experimenting with table state and URL sync.',
|
||||
created_at: now,
|
||||
price: 990,
|
||||
category: 'Electronics',
|
||||
updated_at: now
|
||||
}
|
||||
];
|
||||
|
||||
export const exampleUsers: User[] = [
|
||||
{
|
||||
id: 'user-1',
|
||||
name: 'Mina Parker',
|
||||
email: 'mina@example.com',
|
||||
systemRole: 'super_admin',
|
||||
activeMembershipRole: 'admin',
|
||||
activeOrganizationId: 'org-atlas',
|
||||
organizations: [
|
||||
{ id: 'org-atlas', name: 'Atlas Labs', role: 'admin' },
|
||||
{ id: 'org-nova', name: 'Nova Retail', role: 'admin' }
|
||||
],
|
||||
memberships: [
|
||||
{
|
||||
organizationId: 'org-atlas',
|
||||
organizationName: 'Atlas Labs',
|
||||
membershipRole: 'admin',
|
||||
businessRole: 'it_admin'
|
||||
},
|
||||
{
|
||||
organizationId: 'org-nova',
|
||||
organizationName: 'Nova Retail',
|
||||
membershipRole: 'admin',
|
||||
businessRole: 'auditor'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'user-2',
|
||||
name: 'Noah Bennett',
|
||||
email: 'noah@example.com',
|
||||
systemRole: 'user',
|
||||
activeMembershipRole: 'admin',
|
||||
activeOrganizationId: 'org-atlas',
|
||||
organizations: [{ id: 'org-atlas', name: 'Atlas Labs', role: 'admin' }],
|
||||
memberships: [
|
||||
{
|
||||
organizationId: 'org-atlas',
|
||||
organizationName: 'Atlas Labs',
|
||||
membershipRole: 'admin',
|
||||
businessRole: 'helpdesk'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'user-3',
|
||||
name: 'Lina Torres',
|
||||
email: 'lina@example.com',
|
||||
systemRole: 'user',
|
||||
activeMembershipRole: 'user',
|
||||
activeOrganizationId: 'org-atlas',
|
||||
organizations: [{ id: 'org-atlas', name: 'Atlas Labs', role: 'user' }],
|
||||
memberships: [
|
||||
{
|
||||
organizationId: 'org-atlas',
|
||||
organizationName: 'Atlas Labs',
|
||||
membershipRole: 'user',
|
||||
businessRole: 'application'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'user-4',
|
||||
name: 'Kai Morgan',
|
||||
email: 'kai@example.com',
|
||||
systemRole: 'user',
|
||||
activeMembershipRole: 'admin',
|
||||
activeOrganizationId: 'org-nova',
|
||||
organizations: [{ id: 'org-nova', name: 'Nova Retail', role: 'admin' }],
|
||||
memberships: [
|
||||
{
|
||||
organizationId: 'org-nova',
|
||||
organizationName: 'Nova Retail',
|
||||
membershipRole: 'admin',
|
||||
businessRole: 'infrastructure'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'user-5',
|
||||
name: 'Pim Srisuk',
|
||||
email: 'pim@example.com',
|
||||
systemRole: 'user',
|
||||
activeMembershipRole: 'user',
|
||||
activeOrganizationId: 'org-nova',
|
||||
organizations: [{ id: 'org-nova', name: 'Nova Retail', role: 'user' }],
|
||||
memberships: [
|
||||
{
|
||||
organizationId: 'org-nova',
|
||||
organizationName: 'Nova Retail',
|
||||
membershipRole: 'user',
|
||||
businessRole: 'viewer'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'user-6',
|
||||
name: 'Sara Kim',
|
||||
email: 'sara@example.com',
|
||||
systemRole: 'user',
|
||||
activeMembershipRole: 'user',
|
||||
activeOrganizationId: 'org-orbit',
|
||||
organizations: [{ id: 'org-orbit', name: 'Orbit Health', role: 'user' }],
|
||||
memberships: [
|
||||
{
|
||||
organizationId: 'org-orbit',
|
||||
organizationName: 'Orbit Health',
|
||||
membershipRole: 'user',
|
||||
businessRole: 'auditor'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'user-7',
|
||||
name: 'Theo Walsh',
|
||||
email: 'theo@example.com',
|
||||
systemRole: 'user',
|
||||
activeMembershipRole: 'admin',
|
||||
activeOrganizationId: 'org-orbit',
|
||||
organizations: [{ id: 'org-orbit', name: 'Orbit Health', role: 'admin' }],
|
||||
memberships: [
|
||||
{
|
||||
organizationId: 'org-orbit',
|
||||
organizationName: 'Orbit Health',
|
||||
membershipRole: 'admin',
|
||||
businessRole: 'it_admin'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'user-8',
|
||||
name: 'Mew Lert',
|
||||
email: 'mew@example.com',
|
||||
systemRole: 'user',
|
||||
activeMembershipRole: 'user',
|
||||
activeOrganizationId: 'org-atlas',
|
||||
organizations: [
|
||||
{ id: 'org-atlas', name: 'Atlas Labs', role: 'user' },
|
||||
{ id: 'org-orbit', name: 'Orbit Health', role: 'user' }
|
||||
],
|
||||
memberships: [
|
||||
{
|
||||
organizationId: 'org-atlas',
|
||||
organizationName: 'Atlas Labs',
|
||||
membershipRole: 'user',
|
||||
businessRole: 'helpdesk'
|
||||
},
|
||||
{
|
||||
organizationId: 'org-orbit',
|
||||
organizationName: 'Orbit Health',
|
||||
membershipRole: 'user',
|
||||
businessRole: 'viewer'
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
395
src/features/forms/components/advanced-form-patterns.tsx
Normal file
395
src/features/forms/components/advanced-form-patterns.tsx
Normal file
@@ -0,0 +1,395 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import {
|
||||
useAppForm,
|
||||
useFormFields,
|
||||
FormErrors,
|
||||
scrollToFirstError
|
||||
} from '@/components/ui/tanstack-form';
|
||||
import { useStore } from '@tanstack/react-form';
|
||||
import { z } from 'zod';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Icons } from '@/components/icons';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type AdvancedFormValues = {
|
||||
username: string;
|
||||
email: string;
|
||||
password: string;
|
||||
confirmPassword: string;
|
||||
team: {
|
||||
name: string;
|
||||
size: number;
|
||||
};
|
||||
members: Array<{ name: string; role: string }>;
|
||||
country: string;
|
||||
state: string;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Country / State data
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const countryStateMap: Record<string, { value: string; label: string }[]> = {
|
||||
us: [
|
||||
{ value: 'ca', label: 'California' },
|
||||
{ value: 'ny', label: 'New York' },
|
||||
{ value: 'tx', label: 'Texas' }
|
||||
],
|
||||
uk: [
|
||||
{ value: 'ldn', label: 'London' },
|
||||
{ value: 'mnc', label: 'Manchester' },
|
||||
{ value: 'brm', label: 'Birmingham' }
|
||||
],
|
||||
au: [
|
||||
{ value: 'nsw', label: 'New South Wales' },
|
||||
{ value: 'vic', label: 'Victoria' },
|
||||
{ value: 'qld', label: 'Queensland' }
|
||||
]
|
||||
};
|
||||
|
||||
const countryOptions = [
|
||||
{ value: 'us', label: 'United States' },
|
||||
{ value: 'uk', label: 'United Kingdom' },
|
||||
{ value: 'au', label: 'Australia' }
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Form-level Zod schema (cross-field validation on submit)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const advancedSchema = z.object({
|
||||
username: z.string().min(3),
|
||||
email: z.string().email(),
|
||||
password: z.string().min(8),
|
||||
confirmPassword: z.string().min(1),
|
||||
team: z.object({
|
||||
name: z.string().min(2),
|
||||
size: z.number().min(1).max(100)
|
||||
}),
|
||||
members: z
|
||||
.array(
|
||||
z.object({
|
||||
name: z.string().min(1, 'Member name is required'),
|
||||
role: z.string().min(1, 'Role is required')
|
||||
})
|
||||
)
|
||||
.min(1, 'Add at least one member'),
|
||||
country: z.string().min(1, 'Select a country'),
|
||||
state: z.string().min(1, 'Select a state')
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function AdvancedFormPatterns() {
|
||||
const form = useAppForm({
|
||||
defaultValues: {
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
team: {
|
||||
name: '',
|
||||
size: 1
|
||||
},
|
||||
members: [{ name: '', role: '' }],
|
||||
country: '',
|
||||
state: ''
|
||||
} as AdvancedFormValues,
|
||||
validators: {
|
||||
onSubmit: advancedSchema
|
||||
},
|
||||
onSubmit: () => {
|
||||
toast.success('Team registered successfully!');
|
||||
},
|
||||
onSubmitInvalid: () => {
|
||||
scrollToFirstError();
|
||||
}
|
||||
});
|
||||
|
||||
const { FormTextField, FormSelectField } = useFormFields<AdvancedFormValues>();
|
||||
|
||||
// Read current country reactively for dependent state field
|
||||
const selectedCountry = useStore(form.store, (s) => s.values.country);
|
||||
const stateOptions = countryStateMap[selectedCountry] ?? [];
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className='text-2xl font-bold'>Team Registration</CardTitle>
|
||||
<p className='text-muted-foreground'>
|
||||
Demonstrates async validation, linked fields, nested objects, dynamic arrays, listeners,
|
||||
form-level errors, and scroll-to-first-error.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form.AppForm>
|
||||
<form.Form className='space-y-6'>
|
||||
{/* Form-level error display */}
|
||||
<FormErrors />
|
||||
|
||||
{/* ─── Section 1: Account ─── */}
|
||||
<div className='space-y-1'>
|
||||
<h3 className='text-lg font-semibold'>Account</h3>
|
||||
<p className='text-muted-foreground text-sm'>Async validation, linked fields</p>
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2'>
|
||||
{/* Username — async validation (spinner built into FormTextField) */}
|
||||
<FormTextField
|
||||
name='username'
|
||||
label='Username'
|
||||
required
|
||||
placeholder='Choose a username'
|
||||
validators={{
|
||||
onBlur: z.string().min(3, 'Username must be at least 3 characters'),
|
||||
onChangeAsync: async ({ value }: { value: string }) => {
|
||||
if (!value || value.length < 3) return undefined;
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
if (value === 'admin' || value === 'test') {
|
||||
return 'Username is taken';
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
onChangeAsyncDebounceMs: 500
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Email */}
|
||||
<FormTextField
|
||||
name='email'
|
||||
label='Email'
|
||||
required
|
||||
type='email'
|
||||
placeholder='you@example.com'
|
||||
validators={{
|
||||
onBlur: z.string().email('Invalid email')
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Password */}
|
||||
<FormTextField
|
||||
name='password'
|
||||
label='Password'
|
||||
required
|
||||
type='password'
|
||||
placeholder='Min 8 characters'
|
||||
validators={{
|
||||
onBlur: z.string().min(8, 'Must be at least 8 characters')
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Confirm Password — linked validation via AppField render prop */}
|
||||
<form.AppField
|
||||
name='confirmPassword'
|
||||
validators={{
|
||||
onChangeListenTo: ['password'],
|
||||
onChange: ({ value, fieldApi }) => {
|
||||
const password = fieldApi.form.getFieldValue('password');
|
||||
if (value !== password) return 'Passwords do not match';
|
||||
return undefined;
|
||||
},
|
||||
onBlur: z.string().min(1, 'Please confirm your password')
|
||||
}}
|
||||
>
|
||||
{(field) => (
|
||||
<field.TextField
|
||||
label='Confirm Password'
|
||||
required
|
||||
type='password'
|
||||
placeholder='Confirm password'
|
||||
/>
|
||||
)}
|
||||
</form.AppField>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* ─── Section 2: Team Info (nested objects) ─── */}
|
||||
<div className='space-y-1'>
|
||||
<h3 className='text-lg font-semibold'>Team Info</h3>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
Nested objects with dot-notation paths
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2'>
|
||||
<FormTextField
|
||||
name='team.name'
|
||||
label='Team Name'
|
||||
required
|
||||
placeholder='e.g. Alpha Squad'
|
||||
validators={{
|
||||
onBlur: z.string().min(2, 'Team name must be at least 2 characters')
|
||||
}}
|
||||
/>
|
||||
<FormTextField
|
||||
name='team.size'
|
||||
label='Team Size'
|
||||
required
|
||||
type='number'
|
||||
min={1}
|
||||
max={100}
|
||||
placeholder='1-100'
|
||||
validators={{
|
||||
onBlur: z.number().min(1, 'At least 1 member').max(100, 'Max 100 members')
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* ─── Section 3: Members (dynamic array rows) ─── */}
|
||||
<div className='space-y-1'>
|
||||
<h3 className='text-lg font-semibold'>Members</h3>
|
||||
<p className='text-muted-foreground text-sm'>Dynamic array rows with add / remove</p>
|
||||
</div>
|
||||
|
||||
<form.AppField name='members' mode='array'>
|
||||
{(field) => (
|
||||
<div className='space-y-3'>
|
||||
{field.state.value.map((_, i) => (
|
||||
<div key={i} className='flex items-start gap-2'>
|
||||
<form.AppField
|
||||
name={`members[${i}].name`}
|
||||
validators={{
|
||||
onBlur: z.string().min(1, 'Member name is required')
|
||||
}}
|
||||
>
|
||||
{(subField) => (
|
||||
<subField.FieldSet className='flex-1'>
|
||||
<subField.Field>
|
||||
<Input
|
||||
placeholder='Member name'
|
||||
value={subField.state.value}
|
||||
onChange={(e) => subField.handleChange(e.target.value)}
|
||||
onBlur={subField.handleBlur}
|
||||
/>
|
||||
</subField.Field>
|
||||
<subField.FieldError />
|
||||
</subField.FieldSet>
|
||||
)}
|
||||
</form.AppField>
|
||||
<form.AppField
|
||||
name={`members[${i}].role`}
|
||||
validators={{
|
||||
onBlur: z.string().min(1, 'Role is required')
|
||||
}}
|
||||
>
|
||||
{(subField) => (
|
||||
<subField.FieldSet className='flex-1'>
|
||||
<subField.Field>
|
||||
<Input
|
||||
placeholder='Role'
|
||||
value={subField.state.value}
|
||||
onChange={(e) => subField.handleChange(e.target.value)}
|
||||
onBlur={subField.handleBlur}
|
||||
/>
|
||||
</subField.Field>
|
||||
<subField.FieldError />
|
||||
</subField.FieldSet>
|
||||
)}
|
||||
</form.AppField>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
onClick={() => field.removeValue(i)}
|
||||
>
|
||||
<Icons.close className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => field.pushValue({ name: '', role: '' })}
|
||||
>
|
||||
<Icons.add className='mr-2 h-4 w-4' /> Add Member
|
||||
</Button>
|
||||
{field.state.value.length > 0 && (
|
||||
<div className='flex flex-wrap gap-1'>
|
||||
{field.state.value
|
||||
.filter((m) => m.name)
|
||||
.map((m, idx) => (
|
||||
<Badge key={idx} variant='secondary'>
|
||||
{m.name}
|
||||
{m.role ? ` (${m.role})` : ''}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</form.AppField>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* ─── Section 4: Preferences (listeners / side effects) ─── */}
|
||||
<div className='space-y-1'>
|
||||
<h3 className='text-lg font-semibold'>Preferences</h3>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
Listener side effects — country resets state
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2'>
|
||||
<FormSelectField
|
||||
name='country'
|
||||
label='Country'
|
||||
required
|
||||
options={countryOptions}
|
||||
placeholder='Select a country'
|
||||
validators={{
|
||||
onBlur: z.string().min(1, 'Select a country')
|
||||
}}
|
||||
listeners={{
|
||||
onChange: ({ fieldApi }) => {
|
||||
fieldApi.form.setFieldValue('state', '');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<FormSelectField
|
||||
name='state'
|
||||
label='State / Region'
|
||||
required
|
||||
options={stateOptions}
|
||||
placeholder={selectedCountry ? 'Select state' : 'Select a country first'}
|
||||
validators={{
|
||||
onBlur: z.string().min(1, 'Please select a state')
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* ─── Submit ─── */}
|
||||
<div className='flex gap-4 pt-2'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
onClick={() => form.reset()}
|
||||
className='flex-1'
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
<form.SubmitButton className='flex-1'>Register Team</form.SubmitButton>
|
||||
</div>
|
||||
</form.Form>
|
||||
</form.AppForm>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
11
src/features/forms/components/forms-showcase-page.tsx
Normal file
11
src/features/forms/components/forms-showcase-page.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import MultiStepProductForm from './multi-step-product-form';
|
||||
|
||||
export default function FormsShowcasePage() {
|
||||
return (
|
||||
<div className='max-w-2xl'>
|
||||
<MultiStepProductForm />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
301
src/features/forms/components/multi-step-product-form.tsx
Normal file
301
src/features/forms/components/multi-step-product-form.tsx
Normal file
@@ -0,0 +1,301 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { useAppForm, withFieldGroup } from '@/components/ui/tanstack-form';
|
||||
import { revalidateLogic, useStore } from '@tanstack/react-form';
|
||||
import { toast } from 'sonner';
|
||||
import * as z from 'zod';
|
||||
import { Icons } from '@/components/icons';
|
||||
import { FieldDescription } from '@/components/ui/field';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { useFormStepper } from '@/hooks/use-stepper';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
|
||||
// --- Schema ---
|
||||
|
||||
const productFormSchema = z.object({
|
||||
name: z.string().min(2, 'Product name must be at least 2 characters'),
|
||||
category: z.string().min(1, 'Please select a category'),
|
||||
price: z.number().min(0.01, 'Price must be greater than 0'),
|
||||
description: z.string().min(10, 'Description must be at least 10 characters')
|
||||
});
|
||||
|
||||
const stepSchemas = [
|
||||
// Step 1: Basic Info
|
||||
productFormSchema.pick({ name: true, category: true, price: true }),
|
||||
// Step 2: Details
|
||||
productFormSchema.pick({ description: true }),
|
||||
// Step 3: Review (no validation)
|
||||
z.object({})
|
||||
];
|
||||
|
||||
// --- Step Groups ---
|
||||
|
||||
const categoryOptions = [
|
||||
{ value: 'beauty', label: 'Beauty Products' },
|
||||
{ value: 'electronics', label: 'Electronics' },
|
||||
{ value: 'home', label: 'Home & Garden' },
|
||||
{ value: 'sports', label: 'Sports & Outdoors' }
|
||||
];
|
||||
|
||||
const Step1Group = withFieldGroup({
|
||||
defaultValues: {
|
||||
name: '',
|
||||
category: '',
|
||||
price: undefined as number | undefined
|
||||
},
|
||||
render: function Step1Render({ group }) {
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
<h3 className='text-lg font-semibold'>Basic Info</h3>
|
||||
<FieldDescription>Enter the product name, category, and price.</FieldDescription>
|
||||
|
||||
<group.AppField name='name'>
|
||||
{(field) => (
|
||||
<field.TextField label='Product Name' required placeholder='Enter product name' />
|
||||
)}
|
||||
</group.AppField>
|
||||
|
||||
<group.AppField name='category'>
|
||||
{(field) => (
|
||||
<field.SelectField
|
||||
label='Category'
|
||||
required
|
||||
options={categoryOptions}
|
||||
placeholder='Select category'
|
||||
/>
|
||||
)}
|
||||
</group.AppField>
|
||||
|
||||
<group.AppField name='price'>
|
||||
{(field) => (
|
||||
<field.TextField
|
||||
label='Price'
|
||||
required
|
||||
type='number'
|
||||
min={0}
|
||||
step={0.01}
|
||||
placeholder='Enter price'
|
||||
/>
|
||||
)}
|
||||
</group.AppField>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const Step2Group = withFieldGroup({
|
||||
defaultValues: {
|
||||
description: ''
|
||||
},
|
||||
render: function Step2Render({ group }) {
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
<h3 className='text-lg font-semibold'>Details</h3>
|
||||
<FieldDescription>Add a detailed product description.</FieldDescription>
|
||||
|
||||
<group.AppField name='description'>
|
||||
{(field) => (
|
||||
<field.TextareaField
|
||||
label='Description'
|
||||
required
|
||||
placeholder='Enter product description'
|
||||
maxLength={500}
|
||||
rows={5}
|
||||
/>
|
||||
)}
|
||||
</group.AppField>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const Step3Group = withFieldGroup({
|
||||
defaultValues: {},
|
||||
render: function Step3Render() {
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
<h3 className='text-lg font-semibold'>Review & Submit</h3>
|
||||
<FieldDescription>Review the details below before submitting.</FieldDescription>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// --- Review component (reads form values) ---
|
||||
|
||||
function ReviewSummary({
|
||||
values
|
||||
}: {
|
||||
values: {
|
||||
name: string;
|
||||
category: string;
|
||||
price?: number;
|
||||
description: string;
|
||||
};
|
||||
}) {
|
||||
return (
|
||||
<div className='space-y-3'>
|
||||
<Separator />
|
||||
<div className='grid gap-3'>
|
||||
<div>
|
||||
<p className='text-muted-foreground text-xs font-medium uppercase'>Name</p>
|
||||
<p className='text-sm'>{values.name || '—'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-muted-foreground text-xs font-medium uppercase'>Category</p>
|
||||
<p className='text-sm capitalize'>{values.category || '—'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-muted-foreground text-xs font-medium uppercase'>Price</p>
|
||||
<p className='text-sm'>{values.price != null ? `$${values.price}` : '—'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-muted-foreground text-xs font-medium uppercase'>Description</p>
|
||||
<p className='text-sm'>{values.description || '—'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Main Form ---
|
||||
|
||||
type ProductFormValues = {
|
||||
name: string;
|
||||
category: string;
|
||||
price: number | undefined;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export default function MultiStepProductForm() {
|
||||
const {
|
||||
currentValidator,
|
||||
step,
|
||||
currentStep,
|
||||
isFirstStep,
|
||||
handleCancelOrBack,
|
||||
handleNextStepOrSubmit
|
||||
} = useFormStepper(stepSchemas);
|
||||
|
||||
const form = useAppForm({
|
||||
defaultValues: {
|
||||
name: '',
|
||||
category: '',
|
||||
price: undefined,
|
||||
description: ''
|
||||
} as ProductFormValues,
|
||||
validationLogic: revalidateLogic(),
|
||||
validators: {
|
||||
onDynamic: currentValidator as typeof productFormSchema,
|
||||
onDynamicAsyncDebounceMs: 500
|
||||
},
|
||||
onSubmit: () => {
|
||||
toast.success('Product created successfully!');
|
||||
}
|
||||
});
|
||||
|
||||
const isDefault = useStore(form.store, (state) => state.isDefaultValue);
|
||||
const formValues = useStore(form.store, (state) => state.values);
|
||||
|
||||
const groups: Record<number, React.ReactNode> = {
|
||||
1: <Step1Group form={form} fields={{ name: 'name', category: 'category', price: 'price' }} />,
|
||||
2: <Step2Group form={form} fields={{ description: 'description' }} />,
|
||||
3: (
|
||||
<>
|
||||
<Step3Group form={form} fields={{}} />
|
||||
<ReviewSummary values={formValues} />
|
||||
</>
|
||||
)
|
||||
};
|
||||
|
||||
const handleNext = async () => {
|
||||
await handleNextStepOrSubmit(form);
|
||||
};
|
||||
|
||||
const current = groups[currentStep];
|
||||
|
||||
return (
|
||||
<form.AppForm>
|
||||
<form.Form className='p-0 md:p-0'>
|
||||
<div className='flex flex-col gap-2 pt-3'>
|
||||
<div className='flex flex-col items-center justify-start gap-1'>
|
||||
<span className='text-muted-foreground text-sm'>
|
||||
Step {currentStep} of {Object.keys(groups).length}
|
||||
</span>
|
||||
<Progress value={(currentStep / Object.keys(groups).length) * 100} />
|
||||
</div>
|
||||
|
||||
<AnimatePresence mode='popLayout'>
|
||||
<motion.div
|
||||
key={currentStep}
|
||||
initial={{ opacity: 0, x: 15 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -15 }}
|
||||
transition={{ duration: 0.4, type: 'spring' }}
|
||||
className='flex flex-col gap-2'
|
||||
>
|
||||
{current}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
|
||||
<div className='flex w-full items-center justify-between gap-3 pt-3'>
|
||||
<form.StepButton
|
||||
label={
|
||||
<>
|
||||
<Icons.chevronLeft /> Previous
|
||||
</>
|
||||
}
|
||||
disabled={isFirstStep}
|
||||
handleMovement={() =>
|
||||
handleCancelOrBack({
|
||||
onBack: () => {}
|
||||
})
|
||||
}
|
||||
/>
|
||||
{step.isCompleted ? (
|
||||
<div className='flex w-full items-center justify-end gap-3 pt-3'>
|
||||
{!isDefault && (
|
||||
<Button
|
||||
type='button'
|
||||
onClick={() => form.reset()}
|
||||
className='rounded-lg'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
)}
|
||||
<form.SubmitButton>Submit</form.SubmitButton>
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex w-full items-center justify-end gap-3 pt-3'>
|
||||
{!isDefault && (
|
||||
<Button
|
||||
type='button'
|
||||
onClick={() => form.reset()}
|
||||
className='rounded-lg'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
)}
|
||||
<form.StepButton
|
||||
label={
|
||||
<>
|
||||
Next <Icons.chevronRight />
|
||||
</>
|
||||
}
|
||||
handleMovement={handleNext}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</form.Form>
|
||||
</form.AppForm>
|
||||
);
|
||||
}
|
||||
322
src/features/forms/components/sheet-form-demo.tsx
Normal file
322
src/features/forms/components/sheet-form-demo.tsx
Normal file
@@ -0,0 +1,322 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useAppForm, useFormFields } from '@/components/ui/tanstack-form';
|
||||
import * as z from 'zod';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger
|
||||
} from '@/components/ui/sheet';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger
|
||||
} from '@/components/ui/dialog';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Icons } from '@/components/icons';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type SheetFormValues = {
|
||||
name: string;
|
||||
category: string;
|
||||
price: number | undefined;
|
||||
description: string;
|
||||
};
|
||||
|
||||
type DialogFormValues = {
|
||||
rating: number;
|
||||
feedback: string;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Options
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const categoryOptions = [
|
||||
{ value: 'beauty', label: 'Beauty Products' },
|
||||
{ value: 'electronics', label: 'Electronics' },
|
||||
{ value: 'home', label: 'Home & Garden' },
|
||||
{ value: 'sports', label: 'Sports & Outdoors' }
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sheet Form
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function SheetFormSection() {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const form = useAppForm({
|
||||
defaultValues: {
|
||||
name: '',
|
||||
category: '',
|
||||
price: undefined,
|
||||
description: ''
|
||||
} as SheetFormValues,
|
||||
onSubmit: ({ value }) => {
|
||||
toast.success('Product created successfully!', {
|
||||
description: `${value.name} has been added.`
|
||||
});
|
||||
setOpen(false);
|
||||
form.reset();
|
||||
}
|
||||
});
|
||||
|
||||
const { FormTextField, FormSelectField, FormTextareaField } = useFormFields<SheetFormValues>();
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Sheet Form</CardTitle>
|
||||
<CardDescription>
|
||||
A product creation form inside a Sheet. The submit button lives in the SheetFooter,
|
||||
outside the form element, connected via the HTML{' '}
|
||||
<code className='bg-muted rounded px-1 text-sm'>form</code> attribute.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<Button>
|
||||
<Icons.add className='mr-2 h-4 w-4' />
|
||||
Add Product
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent className='flex flex-col'>
|
||||
<SheetHeader>
|
||||
<SheetTitle>New Product</SheetTitle>
|
||||
<SheetDescription>
|
||||
Fill in the details below to create a new product.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<form.AppForm>
|
||||
<form.Form id='sheet-form-id' className='space-y-4 p-0 md:p-0'>
|
||||
<FormTextField
|
||||
name='name'
|
||||
label='Product Name'
|
||||
required
|
||||
placeholder='Enter product name'
|
||||
validators={{
|
||||
onBlur: z.string().min(2, 'Product name must be at least 2 characters')
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormSelectField
|
||||
name='category'
|
||||
label='Category'
|
||||
required
|
||||
options={categoryOptions}
|
||||
placeholder='Select a category'
|
||||
validators={{
|
||||
onBlur: z.string().min(1, 'Please select a category')
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormTextField
|
||||
name='price'
|
||||
label='Price'
|
||||
required
|
||||
type='number'
|
||||
min={0}
|
||||
step='0.01'
|
||||
placeholder='0.00'
|
||||
validators={{
|
||||
onBlur: z.number().min(0.01, 'Price must be greater than 0')
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormTextareaField
|
||||
name='description'
|
||||
label='Description'
|
||||
required
|
||||
placeholder='Enter product description'
|
||||
maxLength={500}
|
||||
rows={4}
|
||||
validators={{
|
||||
onBlur: z.string().min(10, 'Description must be at least 10 characters')
|
||||
}}
|
||||
/>
|
||||
</form.Form>
|
||||
</form.AppForm>
|
||||
|
||||
<SheetFooter className='pt-4'>
|
||||
<Button type='button' variant='outline' onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type='submit' form='sheet-form-id'>
|
||||
Create Product
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dialog Form
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function DialogFormSection() {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const form = useAppForm({
|
||||
defaultValues: {
|
||||
rating: 5,
|
||||
feedback: ''
|
||||
} as DialogFormValues,
|
||||
onSubmit: ({ value }) => {
|
||||
toast.success('Feedback submitted!', {
|
||||
description: `Rating: ${value.rating}/10. Thank you!`
|
||||
});
|
||||
setOpen(false);
|
||||
form.reset();
|
||||
}
|
||||
});
|
||||
|
||||
const { FormSliderField, FormTextareaField } = useFormFields<DialogFormValues>();
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Dialog Form</CardTitle>
|
||||
<CardDescription>
|
||||
A quick feedback form inside a Dialog. Uses composed field components from{' '}
|
||||
<code className='bg-muted rounded px-1 text-sm'>useFormFields</code> with the submit
|
||||
button in the DialogFooter.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant='outline'>
|
||||
<Icons.send className='mr-2 h-4 w-4' />
|
||||
Send Feedback
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Quick Feedback</DialogTitle>
|
||||
<DialogDescription>Rate your experience and leave a comment.</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form.AppForm>
|
||||
<form.Form id='dialog-form-id' className='space-y-4 py-2'>
|
||||
<FormSliderField
|
||||
name='rating'
|
||||
label='Rating'
|
||||
description='Rate your experience (0-10)'
|
||||
min={0}
|
||||
max={10}
|
||||
step={1}
|
||||
/>
|
||||
|
||||
<FormTextareaField
|
||||
name='feedback'
|
||||
label='Feedback'
|
||||
required
|
||||
placeholder='Tell us what you think...'
|
||||
maxLength={300}
|
||||
rows={3}
|
||||
validators={{
|
||||
onBlur: z.string().min(5, 'Feedback must be at least 5 characters')
|
||||
}}
|
||||
/>
|
||||
</form.Form>
|
||||
</form.AppForm>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type='button' variant='outline' onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type='submit' form='dialog-form-id'>
|
||||
Submit Feedback
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Toast Demo
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ToastDemoSection() {
|
||||
return (
|
||||
<Card className='md:col-span-2'>
|
||||
<CardHeader>
|
||||
<CardTitle>Toast Notifications</CardTitle>
|
||||
<CardDescription>
|
||||
Trigger different toast variants to preview notification styles.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='flex flex-wrap gap-2'>
|
||||
<Button variant='outline' onClick={() => toast('Default toast notification')}>
|
||||
Default
|
||||
</Button>
|
||||
<Button variant='outline' onClick={() => toast.success('Action completed successfully!')}>
|
||||
<Icons.circleCheck className='mr-2 h-4 w-4' />
|
||||
Success
|
||||
</Button>
|
||||
<Button variant='outline' onClick={() => toast.error('Something went wrong.')}>
|
||||
<Icons.circleX className='mr-2 h-4 w-4' />
|
||||
Error
|
||||
</Button>
|
||||
<Button variant='outline' onClick={() => toast.warning('Please review before continuing.')}>
|
||||
<Icons.warning className='mr-2 h-4 w-4' />
|
||||
Warning
|
||||
</Button>
|
||||
<Button variant='outline' onClick={() => toast.info('Here is some useful information.')}>
|
||||
<Icons.info className='mr-2 h-4 w-4' />
|
||||
Info
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={() =>
|
||||
toast.promise(new Promise((resolve) => setTimeout(resolve, 2000)), {
|
||||
loading: 'Loading...',
|
||||
success: 'Data loaded!',
|
||||
error: 'Failed to load.'
|
||||
})
|
||||
}
|
||||
>
|
||||
<Icons.spinner className='mr-2 h-4 w-4' />
|
||||
Promise
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main Demo
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function SheetFormDemo() {
|
||||
return (
|
||||
<div className='grid grid-cols-1 gap-6 md:grid-cols-2'>
|
||||
<SheetFormSection />
|
||||
<DialogFormSection />
|
||||
<ToastDemoSection />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
199
src/features/forms/components/sheet-product-form.tsx
Normal file
199
src/features/forms/components/sheet-product-form.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
'use client';
|
||||
|
||||
import { useAppForm } from '@/components/ui/tanstack-form';
|
||||
import * as z from 'zod';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger
|
||||
} from '@/components/ui/sheet';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@/components/ui/select';
|
||||
import { Icons } from '@/components/icons';
|
||||
import { useState } from 'react';
|
||||
|
||||
const productSchema = z.object({
|
||||
name: z.string().min(2, 'Product name must be at least 2 characters'),
|
||||
category: z.string().min(1, 'Please select a category'),
|
||||
price: z.number().min(0.01, 'Price must be greater than 0'),
|
||||
description: z.string().min(10, 'Description must be at least 10 characters')
|
||||
});
|
||||
|
||||
export default function SheetProductForm() {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const form = useAppForm({
|
||||
defaultValues: {
|
||||
name: '',
|
||||
category: '',
|
||||
price: undefined as number | undefined,
|
||||
description: ''
|
||||
},
|
||||
validators: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- TanStack Form validator type mismatch with Zod
|
||||
onSubmit: productSchema as any
|
||||
},
|
||||
onSubmit: () => {
|
||||
alert('Product created successfully!');
|
||||
setOpen(false);
|
||||
form.reset();
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<Button>
|
||||
<Icons.add className='mr-2 h-4 w-4' />
|
||||
Add Product
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent className='flex flex-col'>
|
||||
<SheetHeader>
|
||||
<SheetTitle>New Product</SheetTitle>
|
||||
<SheetDescription>Fill in the details to create a new product.</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className='flex-1 overflow-auto'>
|
||||
<form.AppForm>
|
||||
<form.Form id='sheet-product-form' className='space-y-4'>
|
||||
<form.AppField
|
||||
name='name'
|
||||
children={(field) => {
|
||||
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid;
|
||||
return (
|
||||
<field.FieldSet>
|
||||
<field.Field>
|
||||
<field.FieldLabel htmlFor={field.name}>Product Name *</field.FieldLabel>
|
||||
<Input
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
placeholder='Enter product name'
|
||||
aria-invalid={isInvalid}
|
||||
/>
|
||||
</field.Field>
|
||||
<field.FieldError />
|
||||
</field.FieldSet>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<form.AppField
|
||||
name='category'
|
||||
children={(field) => {
|
||||
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid;
|
||||
return (
|
||||
<field.FieldSet>
|
||||
<field.Field>
|
||||
<field.FieldLabel htmlFor={field.name}>Category *</field.FieldLabel>
|
||||
<Select
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onValueChange={field.handleChange}
|
||||
>
|
||||
<SelectTrigger id={field.name} aria-invalid={isInvalid}>
|
||||
<SelectValue placeholder='Select category' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='beauty'>Beauty Products</SelectItem>
|
||||
<SelectItem value='electronics'>Electronics</SelectItem>
|
||||
<SelectItem value='home'>Home & Garden</SelectItem>
|
||||
<SelectItem value='sports'>Sports & Outdoors</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</field.Field>
|
||||
<field.FieldError />
|
||||
</field.FieldSet>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<form.AppField
|
||||
name='price'
|
||||
children={(field) => {
|
||||
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid;
|
||||
return (
|
||||
<field.FieldSet>
|
||||
<field.Field>
|
||||
<field.FieldLabel htmlFor={field.name}>Price *</field.FieldLabel>
|
||||
<Input
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
type='number'
|
||||
min={0}
|
||||
step='0.01'
|
||||
value={field.state.value ?? ''}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
field.handleChange(v === '' ? undefined : parseFloat(v));
|
||||
}}
|
||||
placeholder='Enter price'
|
||||
aria-invalid={isInvalid}
|
||||
/>
|
||||
</field.Field>
|
||||
<field.FieldError />
|
||||
</field.FieldSet>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<form.AppField
|
||||
name='description'
|
||||
children={(field) => {
|
||||
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid;
|
||||
return (
|
||||
<field.FieldSet>
|
||||
<field.Field>
|
||||
<field.FieldLabel htmlFor={field.name}>Description *</field.FieldLabel>
|
||||
<Textarea
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
placeholder='Enter product description'
|
||||
maxLength={500}
|
||||
rows={4}
|
||||
aria-invalid={isInvalid}
|
||||
/>
|
||||
<div className='text-muted-foreground text-right text-sm'>
|
||||
{field.state.value?.length || 0} / 500
|
||||
</div>
|
||||
</field.Field>
|
||||
<field.FieldError />
|
||||
</field.FieldSet>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</form.Form>
|
||||
</form.AppForm>
|
||||
</div>
|
||||
|
||||
<SheetFooter>
|
||||
<Button type='button' variant='outline' onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type='submit' form='sheet-product-form'>
|
||||
Create Product
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
44
src/features/kanban/components/board-column.tsx
Normal file
44
src/features/kanban/components/board-column.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
'use client';
|
||||
|
||||
import { Icons } from '@/components/icons';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { KanbanColumn, KanbanColumnHandle } from '@/components/ui/kanban';
|
||||
import type { Task } from '../utils/store';
|
||||
import { TaskCard } from './task-card';
|
||||
|
||||
const COLUMN_TITLES: Record<string, string> = {
|
||||
backlog: 'Backlog',
|
||||
inProgress: 'In Progress',
|
||||
review: 'Review',
|
||||
done: 'Done'
|
||||
};
|
||||
|
||||
interface TaskColumnProps extends Omit<React.ComponentProps<typeof KanbanColumn>, 'children'> {
|
||||
tasks: Task[];
|
||||
}
|
||||
|
||||
export function TaskColumn({ value, tasks, ...props }: TaskColumnProps) {
|
||||
return (
|
||||
<KanbanColumn value={value} className='w-full shrink-0 md:w-[320px]' {...props}>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='text-sm font-semibold'>{COLUMN_TITLES[value] ?? value}</span>
|
||||
<Badge variant='secondary' className='pointer-events-none rounded-sm'>
|
||||
{tasks.length}
|
||||
</Badge>
|
||||
</div>
|
||||
<KanbanColumnHandle asChild>
|
||||
<Button variant='ghost' size='icon'>
|
||||
<Icons.gripVertical className='h-4 w-4' />
|
||||
</Button>
|
||||
</KanbanColumnHandle>
|
||||
</div>
|
||||
<div className='flex flex-col gap-2 p-0.5'>
|
||||
{tasks.map((task) => (
|
||||
<TaskCard key={task.id} task={task} asHandle />
|
||||
))}
|
||||
</div>
|
||||
</KanbanColumn>
|
||||
);
|
||||
}
|
||||
54
src/features/kanban/components/kanban-board.tsx
Normal file
54
src/features/kanban/components/kanban-board.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { Kanban, KanbanBoard as KanbanBoardPrimitive, KanbanOverlay } from '@/components/ui/kanban';
|
||||
import { useTaskStore } from '../utils/store';
|
||||
import { TaskColumn } from './board-column';
|
||||
import { TaskCard } from './task-card';
|
||||
import { createRestrictToContainer } from '../utils/restrict-to-container';
|
||||
|
||||
export function KanbanBoard() {
|
||||
const { columns, setColumns } = useTaskStore();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- factory function, stable after mount
|
||||
const restrictToBoard = useCallback(
|
||||
createRestrictToContainer(() => containerRef.current),
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={containerRef}>
|
||||
<Kanban
|
||||
value={columns}
|
||||
onValueChange={setColumns}
|
||||
getItemValue={(item) => item.id}
|
||||
modifiers={[restrictToBoard]}
|
||||
autoScroll={false}
|
||||
>
|
||||
<div className='w-full overflow-x-auto rounded-md pb-4'>
|
||||
<KanbanBoardPrimitive className='flex flex-col items-start gap-4 md:flex-row'>
|
||||
{Object.entries(columns).map(([columnValue, tasks]) => (
|
||||
<TaskColumn key={columnValue} value={columnValue} tasks={tasks} />
|
||||
))}
|
||||
</KanbanBoardPrimitive>
|
||||
</div>
|
||||
<KanbanOverlay>
|
||||
{({ value, variant }) => {
|
||||
if (variant === 'column') {
|
||||
const tasks = columns[value] ?? [];
|
||||
return <TaskColumn value={value} tasks={tasks} />;
|
||||
}
|
||||
|
||||
const task = Object.values(columns)
|
||||
.flat()
|
||||
.find((task) => task.id === value);
|
||||
|
||||
if (!task) return null;
|
||||
return <TaskCard task={task} />;
|
||||
}}
|
||||
</KanbanOverlay>
|
||||
</Kanban>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
15
src/features/kanban/components/kanban-view-page.tsx
Normal file
15
src/features/kanban/components/kanban-view-page.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import PageContainer from '@/components/layout/page-container';
|
||||
import { KanbanBoard } from './kanban-board';
|
||||
import NewTaskDialog from './new-task-dialog';
|
||||
|
||||
export default function KanbanViewPage() {
|
||||
return (
|
||||
<PageContainer
|
||||
pageTitle='Kanban'
|
||||
pageDescription='Manage tasks with drag and drop'
|
||||
pageHeaderAction={<NewTaskDialog />}
|
||||
>
|
||||
<KanbanBoard />
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
66
src/features/kanban/components/new-task-dialog.tsx
Normal file
66
src/features/kanban/components/new-task-dialog.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useTaskStore } from '../utils/store';
|
||||
|
||||
export default function NewTaskDialog() {
|
||||
const addTask = useTaskStore((state) => state.addTask);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
const form = e.currentTarget;
|
||||
const formData = new FormData(form);
|
||||
const { title, description } = Object.fromEntries(formData);
|
||||
|
||||
if (typeof title !== 'string' || typeof description !== 'string') return;
|
||||
addTask(title, description);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant='secondary' size='sm'>
|
||||
+ Add New Task
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className='sm:max-w-[425px]'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add New Task</DialogTitle>
|
||||
<DialogDescription>What do you want to get done today?</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form id='task-form' className='grid gap-4 py-4' onSubmit={handleSubmit}>
|
||||
<div className='grid grid-cols-4 items-center gap-4'>
|
||||
<Input id='title' name='title' placeholder='Task title...' className='col-span-4' />
|
||||
</div>
|
||||
<div className='grid grid-cols-4 items-center gap-4'>
|
||||
<Textarea
|
||||
id='description'
|
||||
name='description'
|
||||
placeholder='Description...'
|
||||
className='col-span-4'
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
<DialogFooter>
|
||||
<DialogTrigger asChild>
|
||||
<Button type='submit' size='sm' form='task-form'>
|
||||
Add Task
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
44
src/features/kanban/components/task-card.tsx
Normal file
44
src/features/kanban/components/task-card.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
'use client';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { KanbanItem } from '@/components/ui/kanban';
|
||||
import type { Task } from '../utils/store';
|
||||
|
||||
interface TaskCardProps extends Omit<React.ComponentProps<typeof KanbanItem>, 'value'> {
|
||||
task: Task;
|
||||
}
|
||||
|
||||
export function TaskCard({ task, ...props }: TaskCardProps) {
|
||||
return (
|
||||
<KanbanItem key={task.id} value={task.id} asChild {...props}>
|
||||
<div className='bg-card rounded-md border p-3 shadow-xs'>
|
||||
<div className='flex flex-col gap-2'>
|
||||
<div className='flex items-center justify-between gap-2'>
|
||||
<span className='line-clamp-1 text-sm font-medium'>{task.title}</span>
|
||||
<Badge
|
||||
variant={
|
||||
task.priority === 'high'
|
||||
? 'destructive'
|
||||
: task.priority === 'medium'
|
||||
? 'default'
|
||||
: 'secondary'
|
||||
}
|
||||
className='pointer-events-none h-5 rounded-sm px-1.5 text-[11px] capitalize'
|
||||
>
|
||||
{task.priority}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className='text-muted-foreground flex items-center justify-between text-xs'>
|
||||
{task.assignee && (
|
||||
<div className='flex items-center gap-1'>
|
||||
<div className='bg-primary/20 size-2 rounded-full' />
|
||||
<span className='line-clamp-1'>{task.assignee}</span>
|
||||
</div>
|
||||
)}
|
||||
{task.dueDate && <time className='text-[10px] tabular-nums'>{task.dueDate}</time>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</KanbanItem>
|
||||
);
|
||||
}
|
||||
24
src/features/kanban/utils/restrict-to-container.ts
Normal file
24
src/features/kanban/utils/restrict-to-container.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { Modifier } from '@dnd-kit/core';
|
||||
|
||||
export function createRestrictToContainer(getElement: () => HTMLElement | null): Modifier {
|
||||
return ({ transform, draggingNodeRect, containerNodeRect: _containerNodeRect }) => {
|
||||
const container = getElement();
|
||||
|
||||
if (!draggingNodeRect || !container) {
|
||||
return transform;
|
||||
}
|
||||
|
||||
const rect = container.getBoundingClientRect();
|
||||
|
||||
const minX = rect.left - draggingNodeRect.left;
|
||||
const maxX = rect.right - draggingNodeRect.right;
|
||||
const minY = rect.top - draggingNodeRect.top;
|
||||
const maxY = rect.bottom - draggingNodeRect.bottom;
|
||||
|
||||
return {
|
||||
...transform,
|
||||
x: Math.min(Math.max(transform.x, minX), maxX),
|
||||
y: Math.min(Math.max(transform.y, minY), maxY)
|
||||
};
|
||||
};
|
||||
}
|
||||
130
src/features/kanban/utils/store.ts
Normal file
130
src/features/kanban/utils/store.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { create } from 'zustand';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
// import { persist } from 'zustand/middleware';
|
||||
|
||||
export type Priority = 'low' | 'medium' | 'high';
|
||||
|
||||
export type Task = {
|
||||
id: string;
|
||||
title: string;
|
||||
priority: Priority;
|
||||
description?: string;
|
||||
assignee?: string;
|
||||
dueDate?: string;
|
||||
};
|
||||
|
||||
type KanbanState = {
|
||||
columns: Record<string, Task[]>;
|
||||
setColumns: (columns: Record<string, Task[]>) => void;
|
||||
addTask: (title: string, description?: string) => void;
|
||||
};
|
||||
|
||||
const initialColumns: Record<string, Task[]> = {
|
||||
backlog: [
|
||||
{
|
||||
id: '1',
|
||||
title: 'Migrate to Stripe billing API',
|
||||
priority: 'high',
|
||||
assignee: 'Sarah Chen',
|
||||
dueDate: '2026-04-08'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: 'Add CSV export to reports',
|
||||
priority: 'medium',
|
||||
assignee: 'Marcus Rivera',
|
||||
dueDate: '2026-04-12'
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
title: 'Update onboarding flow copy',
|
||||
priority: 'low',
|
||||
assignee: 'Priya Sharma',
|
||||
dueDate: '2026-04-15'
|
||||
},
|
||||
{
|
||||
id: '9',
|
||||
title: 'Audit RBAC permissions',
|
||||
priority: 'medium',
|
||||
assignee: 'Jordan Kim',
|
||||
dueDate: '2026-04-10'
|
||||
}
|
||||
],
|
||||
inProgress: [
|
||||
{
|
||||
id: '4',
|
||||
title: 'Refactor notification service',
|
||||
priority: 'high',
|
||||
assignee: 'Alex Turner',
|
||||
dueDate: '2026-04-03'
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
title: 'Build team invitation flow',
|
||||
priority: 'medium',
|
||||
assignee: 'Emily Nakamura',
|
||||
dueDate: '2026-04-06'
|
||||
},
|
||||
{
|
||||
id: '10',
|
||||
title: 'Fix timezone handling in scheduler',
|
||||
priority: 'high',
|
||||
assignee: 'Sarah Chen',
|
||||
dueDate: '2026-04-04'
|
||||
}
|
||||
],
|
||||
done: [
|
||||
{
|
||||
id: '6',
|
||||
title: 'SSO integration with Okta',
|
||||
priority: 'high',
|
||||
assignee: 'Jordan Kim',
|
||||
dueDate: '2026-03-22'
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
title: 'Dashboard analytics charts',
|
||||
priority: 'medium',
|
||||
assignee: 'Marcus Rivera',
|
||||
dueDate: '2026-03-20'
|
||||
},
|
||||
{
|
||||
id: '8',
|
||||
title: 'Webhook retry mechanism',
|
||||
priority: 'low',
|
||||
assignee: 'Alex Turner',
|
||||
dueDate: '2026-03-18'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export const useTaskStore = create<KanbanState>()(
|
||||
// To enable persistence across refreshes, uncomment the persist wrapper below:
|
||||
// persist(
|
||||
(set) => ({
|
||||
columns: initialColumns,
|
||||
|
||||
setColumns: (columns) => set({ columns }),
|
||||
|
||||
addTask: (title, description) =>
|
||||
set((state) => ({
|
||||
columns: {
|
||||
...state.columns,
|
||||
backlog: [
|
||||
{
|
||||
id: uuid(),
|
||||
title,
|
||||
description,
|
||||
priority: 'medium' as Priority,
|
||||
assignee: undefined,
|
||||
dueDate: undefined
|
||||
},
|
||||
...(state.columns.backlog ?? [])
|
||||
]
|
||||
}
|
||||
}))
|
||||
})
|
||||
// ,
|
||||
// { name: 'kanban-store' }
|
||||
// )
|
||||
);
|
||||
100
src/features/notifications/components/notification-center.tsx
Normal file
100
src/features/notifications/components/notification-center.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
'use client';
|
||||
|
||||
import { Icons } from '@/components/icons';
|
||||
import Link from 'next/link';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { NotificationCard } from '@/components/ui/notification-card';
|
||||
import { useNotificationStore } from '../utils/store';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
const MAX_VISIBLE = 5;
|
||||
|
||||
const actionRoutes: Record<string, string> = {
|
||||
view: '/dashboard/workspaces',
|
||||
'view-product': '/dashboard/product',
|
||||
billing: '/dashboard/billing',
|
||||
open: '/dashboard/kanban',
|
||||
'open-chat': '/dashboard/chat'
|
||||
};
|
||||
|
||||
export function NotificationCenter() {
|
||||
const { notifications, markAsRead, markAllAsRead, unreadCount } = useNotificationStore();
|
||||
const router = useRouter();
|
||||
const count = unreadCount();
|
||||
const visibleNotifications = notifications.slice(0, MAX_VISIBLE);
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant='ghost' size='icon' className='relative h-8 w-8'>
|
||||
<Icons.notification className='h-4 w-4' />
|
||||
{count > 0 && (
|
||||
<span className='bg-destructive text-destructive-foreground absolute -top-0.5 -right-0.5 flex h-4 min-w-4 items-center justify-center rounded-full px-1 text-[10px] font-medium'>
|
||||
{count > 9 ? '9+' : count}
|
||||
</span>
|
||||
)}
|
||||
<span className='sr-only'>Notifications</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align='end' className='w-[calc(100vw-2rem)] p-0 sm:w-[380px]' sideOffset={8}>
|
||||
<div className='flex items-center justify-between px-4 py-3'>
|
||||
<Link href='/dashboard/notifications' className='group flex items-center gap-1'>
|
||||
<h4 className='text-sm font-semibold group-hover:underline'>Notifications</h4>
|
||||
<Icons.chevronRight className='text-muted-foreground h-3.5 w-3.5 transition-transform group-hover:translate-x-0.5' />
|
||||
</Link>
|
||||
<div className='flex items-center gap-2'>
|
||||
{count > 0 && (
|
||||
<span className='bg-muted text-muted-foreground rounded-full px-2 py-0.5 text-xs'>
|
||||
{count} new
|
||||
</span>
|
||||
)}
|
||||
{count > 0 && (
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='text-muted-foreground h-auto px-2 py-1 text-xs'
|
||||
onClick={markAllAsRead}
|
||||
>
|
||||
Mark all as read
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
<ScrollArea className='h-[400px]'>
|
||||
{notifications.length === 0 ? (
|
||||
<div className='flex flex-col items-center justify-center py-12'>
|
||||
<Icons.notification className='text-muted-foreground/40 mb-2 h-8 w-8' />
|
||||
<p className='text-muted-foreground text-sm'>No notifications yet</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex flex-col gap-1 p-2'>
|
||||
{visibleNotifications.map((notification) => (
|
||||
<NotificationCard
|
||||
key={notification.id}
|
||||
id={notification.id}
|
||||
title={notification.title}
|
||||
body={notification.body}
|
||||
status={notification.status}
|
||||
createdAt={notification.createdAt}
|
||||
actions={notification.actions}
|
||||
onMarkAsRead={markAsRead}
|
||||
onAction={(notifId, actionId) => {
|
||||
const route = actionRoutes[actionId];
|
||||
if (route) {
|
||||
markAsRead(notifId);
|
||||
router.push(route);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
92
src/features/notifications/components/notifications-page.tsx
Normal file
92
src/features/notifications/components/notifications-page.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
'use client';
|
||||
|
||||
import { Icons } from '@/components/icons';
|
||||
import PageContainer from '@/components/layout/page-container';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { NotificationCard } from '@/components/ui/notification-card';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useNotificationStore } from '../utils/store';
|
||||
|
||||
const actionRoutes: Record<string, string> = {
|
||||
view: '/dashboard/workspaces',
|
||||
'view-product': '/dashboard/product',
|
||||
billing: '/dashboard/billing',
|
||||
open: '/dashboard/kanban',
|
||||
'open-chat': '/dashboard/chat'
|
||||
};
|
||||
|
||||
export default function NotificationsPage() {
|
||||
const { notifications, markAsRead, markAllAsRead, unreadCount } = useNotificationStore();
|
||||
const router = useRouter();
|
||||
const count = unreadCount();
|
||||
|
||||
const unreadNotifications = notifications.filter((n) => n.status === 'unread');
|
||||
const readNotifications = notifications.filter((n) => n.status === 'read');
|
||||
|
||||
const renderList = (items: typeof notifications) => {
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className='flex flex-col items-center justify-center py-16'>
|
||||
<Icons.notification className='text-muted-foreground/40 mb-3 h-10 w-10' />
|
||||
<p className='text-muted-foreground text-sm'>No notifications</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-2'>
|
||||
{items.map((notification) => (
|
||||
<NotificationCard
|
||||
key={notification.id}
|
||||
id={notification.id}
|
||||
title={notification.title}
|
||||
body={notification.body}
|
||||
status={notification.status}
|
||||
createdAt={notification.createdAt}
|
||||
actions={notification.actions}
|
||||
onMarkAsRead={markAsRead}
|
||||
onAction={(notifId, actionId) => {
|
||||
const route = actionRoutes[actionId];
|
||||
if (route) {
|
||||
markAsRead(notifId);
|
||||
router.push(route);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
pageTitle='Notifications'
|
||||
pageDescription='View and manage all your notifications.'
|
||||
pageHeaderAction={
|
||||
count > 0 ? (
|
||||
<Button variant='outline' size='sm' onClick={markAllAsRead}>
|
||||
Mark all as read
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<Tabs defaultValue='all'>
|
||||
<TabsList>
|
||||
<TabsTrigger value='all'>All ({notifications.length})</TabsTrigger>
|
||||
<TabsTrigger value='unread'>Unread ({unreadNotifications.length})</TabsTrigger>
|
||||
<TabsTrigger value='read'>Read ({readNotifications.length})</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value='all' className='mt-4'>
|
||||
{renderList(notifications)}
|
||||
</TabsContent>
|
||||
<TabsContent value='unread' className='mt-4'>
|
||||
{renderList(unreadNotifications)}
|
||||
</TabsContent>
|
||||
<TabsContent value='read' className='mt-4'>
|
||||
{renderList(readNotifications)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
137
src/features/notifications/utils/store.ts
Normal file
137
src/features/notifications/utils/store.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { create } from 'zustand';
|
||||
// import { persist } from 'zustand/middleware';
|
||||
import type { NotificationStatus, NotificationAction } from '@/components/ui/notification-card';
|
||||
|
||||
export type Notification = {
|
||||
id: string;
|
||||
title: string;
|
||||
body: string;
|
||||
status: NotificationStatus;
|
||||
createdAt: string;
|
||||
actions?: NotificationAction[];
|
||||
};
|
||||
|
||||
type NotificationState = {
|
||||
notifications: Notification[];
|
||||
markAsRead: (id: string) => void;
|
||||
markAllAsRead: () => void;
|
||||
removeNotification: (id: string) => void;
|
||||
addNotification: (notification: Omit<Notification, 'status'>) => void;
|
||||
unreadCount: () => number;
|
||||
};
|
||||
|
||||
const mockNotifications: Notification[] = [
|
||||
{
|
||||
id: '1',
|
||||
title: 'New team member joined',
|
||||
body: 'Sarah Connor has joined the Engineering workspace.',
|
||||
status: 'unread',
|
||||
createdAt: new Date(Date.now() - 1000 * 60 * 5).toISOString(),
|
||||
actions: [
|
||||
{
|
||||
id: 'view',
|
||||
label: 'View workspace',
|
||||
type: 'redirect',
|
||||
style: 'primary'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: 'New product added',
|
||||
body: 'A new product "Dashboard Pro" has been added to the catalog.',
|
||||
status: 'unread',
|
||||
createdAt: new Date(Date.now() - 1000 * 60 * 30).toISOString(),
|
||||
actions: [
|
||||
{
|
||||
id: 'view-product',
|
||||
label: 'View products',
|
||||
type: 'redirect',
|
||||
style: 'primary'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
title: 'Billing cycle updated',
|
||||
body: 'Your Pro plan has been renewed. Next invoice on April 24, 2026.',
|
||||
status: 'unread',
|
||||
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 2).toISOString(),
|
||||
actions: [
|
||||
{
|
||||
id: 'billing',
|
||||
label: 'View billing',
|
||||
type: 'redirect',
|
||||
style: 'primary'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
title: 'Task assigned to you',
|
||||
body: 'You have been assigned "Update dashboard analytics" on the Kanban board.',
|
||||
status: 'read',
|
||||
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 24).toISOString(),
|
||||
actions: [
|
||||
{
|
||||
id: 'open',
|
||||
label: 'Open kanban',
|
||||
type: 'redirect',
|
||||
style: 'primary'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
title: 'New message from Alex',
|
||||
body: 'Alex sent you a message: "Hey, can we sync on the overview dashboard?"',
|
||||
status: 'read',
|
||||
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 3).toISOString(),
|
||||
actions: [
|
||||
{
|
||||
id: 'open-chat',
|
||||
label: 'Open chat',
|
||||
type: 'redirect',
|
||||
style: 'primary'
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
export const useNotificationStore = create<NotificationState>()(
|
||||
// To enable persistence across refreshes, uncomment the persist wrapper below:
|
||||
// persist(
|
||||
(set, get) => ({
|
||||
notifications: mockNotifications,
|
||||
|
||||
markAsRead: (id) =>
|
||||
set((state) => ({
|
||||
notifications: state.notifications.map((n) =>
|
||||
n.id === id ? { ...n, status: 'read' as const } : n
|
||||
)
|
||||
})),
|
||||
|
||||
markAllAsRead: () =>
|
||||
set((state) => ({
|
||||
notifications: state.notifications.map((n) => ({
|
||||
...n,
|
||||
status: 'read' as const
|
||||
}))
|
||||
})),
|
||||
|
||||
removeNotification: (id) =>
|
||||
set((state) => ({
|
||||
notifications: state.notifications.filter((n) => n.id !== id)
|
||||
})),
|
||||
|
||||
addNotification: (notification) =>
|
||||
set((state) => ({
|
||||
notifications: [{ ...notification, status: 'unread' as const }, ...state.notifications]
|
||||
})),
|
||||
|
||||
unreadCount: () => get().notifications.filter((n) => n.status === 'unread').length
|
||||
})
|
||||
// ,
|
||||
// { name: 'notifications' }
|
||||
// )
|
||||
);
|
||||
23
src/features/overview/components/area-graph-skeleton.tsx
Normal file
23
src/features/overview/components/area-graph-skeleton.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
|
||||
export function AreaGraphSkeleton() {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Skeleton className='h-6 w-[140px]' />
|
||||
<Skeleton className='h-5 w-[60px] rounded-full' />
|
||||
</div>
|
||||
<Skeleton className='h-4 w-[250px]' />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className='relative aspect-auto h-[280px] w-full'>
|
||||
<div className='from-primary/5 to-primary/20 absolute inset-0 rounded-lg bg-linear-to-t' />
|
||||
<Skeleton className='absolute right-0 bottom-0 left-0 h-[1px]' />
|
||||
<Skeleton className='absolute top-0 bottom-0 left-0 w-[1px]' />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
116
src/features/overview/components/area-graph.tsx
Normal file
116
src/features/overview/components/area-graph.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
'use client';
|
||||
|
||||
import { Area, AreaChart, CartesianGrid, XAxis } from 'recharts';
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
ChartConfig,
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent
|
||||
} from '@/components/ui/chart';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Icons } from '@/components/icons';
|
||||
import React from 'react';
|
||||
|
||||
const chartData = [
|
||||
{ month: 'January', desktop: 342, mobile: 245 },
|
||||
{ month: 'February', desktop: 876, mobile: 654 },
|
||||
{ month: 'March', desktop: 512, mobile: 387 },
|
||||
{ month: 'April', desktop: 629, mobile: 521 },
|
||||
{ month: 'May', desktop: 458, mobile: 412 },
|
||||
{ month: 'June', desktop: 781, mobile: 598 },
|
||||
{ month: 'July', desktop: 394, mobile: 312 },
|
||||
{ month: 'August', desktop: 925, mobile: 743 },
|
||||
{ month: 'September', desktop: 647, mobile: 489 },
|
||||
{ month: 'October', desktop: 532, mobile: 476 },
|
||||
{ month: 'November', desktop: 803, mobile: 687 },
|
||||
{ month: 'December', desktop: 271, mobile: 198 }
|
||||
];
|
||||
|
||||
const chartConfig = {
|
||||
desktop: {
|
||||
label: 'Desktop',
|
||||
color: 'var(--chart-1)'
|
||||
},
|
||||
mobile: {
|
||||
label: 'Mobile',
|
||||
color: 'var(--chart-2)'
|
||||
}
|
||||
} satisfies ChartConfig;
|
||||
|
||||
export function AreaGraph() {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
Dotted Area Chart
|
||||
<Badge variant='outline'>
|
||||
<Icons.trendingUp />
|
||||
-5.2%
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
<CardDescription>Showing total visitors for the last 6 months</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer config={chartConfig}>
|
||||
<AreaChart accessibilityLayer data={chartData}>
|
||||
<CartesianGrid vertical={false} strokeDasharray='3 3' />
|
||||
<XAxis
|
||||
dataKey='month'
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
tickFormatter={(value) => value.slice(0, 3)}
|
||||
/>
|
||||
<ChartTooltip cursor={false} content={<ChartTooltipContent />} />
|
||||
<defs>
|
||||
<DottedBackgroundPattern config={chartConfig} />
|
||||
</defs>
|
||||
<Area
|
||||
dataKey='mobile'
|
||||
type='natural'
|
||||
fill='url(#dotted-background-pattern-mobile)'
|
||||
fillOpacity={0.4}
|
||||
stroke='var(--color-mobile)'
|
||||
stackId='a'
|
||||
strokeWidth={0.8}
|
||||
/>
|
||||
<Area
|
||||
dataKey='desktop'
|
||||
type='natural'
|
||||
fill='url(#dotted-background-pattern-desktop)'
|
||||
fillOpacity={0.4}
|
||||
stroke='var(--color-desktop)'
|
||||
stackId='a'
|
||||
strokeWidth={0.8}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const DottedBackgroundPattern = ({ config }: { config: ChartConfig }) => {
|
||||
const items = Object.fromEntries(
|
||||
Object.entries(config).map(([key, value]) => [key, value.color])
|
||||
);
|
||||
return (
|
||||
<>
|
||||
{Object.entries(items).map(([key, value]) => (
|
||||
<pattern
|
||||
key={key}
|
||||
id={`dotted-background-pattern-${key}`}
|
||||
x='0'
|
||||
y='0'
|
||||
width='7'
|
||||
height='7'
|
||||
patternUnits='userSpaceOnUse'
|
||||
>
|
||||
<circle cx='5' cy='5' r='1.5' fill={value} opacity={0.5}></circle>
|
||||
</pattern>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
29
src/features/overview/components/bar-graph-skeleton.tsx
Normal file
29
src/features/overview/components/bar-graph-skeleton.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
|
||||
export function BarGraphSkeleton() {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Skeleton className='h-6 w-[160px]' />
|
||||
<Skeleton className='h-5 w-[60px] rounded-full' />
|
||||
</div>
|
||||
<Skeleton className='h-4 w-[150px]' />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className='flex aspect-auto h-[280px] w-full items-end justify-around gap-2 pt-8'>
|
||||
{Array.from({ length: 12 }).map((_, i) => (
|
||||
<Skeleton
|
||||
key={i}
|
||||
className='w-full rounded-t-sm'
|
||||
style={{
|
||||
height: `${Math.max(20, Math.random() * 100)}%`
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
144
src/features/overview/components/bar-graph.tsx
Normal file
144
src/features/overview/components/bar-graph.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
'use client';
|
||||
|
||||
import { Bar, BarChart, XAxis } from 'recharts';
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
ChartConfig,
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent
|
||||
} from '@/components/ui/chart';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Icons } from '@/components/icons';
|
||||
|
||||
const chartData = [
|
||||
{ month: 'January', desktop: 186, mobile: 80 },
|
||||
{ month: 'February', desktop: 305, mobile: 200 },
|
||||
{ month: 'March', desktop: 237, mobile: 120 },
|
||||
{ month: 'April', desktop: 73, mobile: 190 },
|
||||
{ month: 'May', desktop: 209, mobile: 130 },
|
||||
{ month: 'June', desktop: 214, mobile: 140 }
|
||||
];
|
||||
|
||||
const chartConfig = {
|
||||
desktop: {
|
||||
label: 'Desktop',
|
||||
color: 'var(--chart-1)'
|
||||
},
|
||||
mobile: {
|
||||
label: 'Mobile',
|
||||
color: 'var(--chart-2)'
|
||||
}
|
||||
} satisfies ChartConfig;
|
||||
|
||||
export function BarGraph() {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
Bar Chart - Multiple
|
||||
<Badge variant='outline'>
|
||||
<Icons.trendingDown />
|
||||
-5.2%
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
<CardDescription>January - June 2025</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer config={chartConfig}>
|
||||
<BarChart accessibilityLayer data={chartData}>
|
||||
<rect
|
||||
x='0'
|
||||
y='0'
|
||||
width='100%'
|
||||
height='85%'
|
||||
fill='url(#default-multiple-pattern-dots)'
|
||||
/>
|
||||
<defs>
|
||||
<DottedBackgroundPattern />
|
||||
</defs>
|
||||
<XAxis
|
||||
dataKey='month'
|
||||
tickLine={false}
|
||||
tickMargin={10}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) => value.slice(0, 3)}
|
||||
/>
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={<ChartTooltipContent indicator='dashed' hideLabel />}
|
||||
/>
|
||||
<Bar
|
||||
dataKey='desktop'
|
||||
color='var(--chart-1)'
|
||||
fill='var(--color-desktop)'
|
||||
shape={<CustomHatchedBar isHatched={false} />}
|
||||
radius={4}
|
||||
/>
|
||||
<Bar
|
||||
dataKey='mobile'
|
||||
fill='var(--color-mobile)'
|
||||
shape={<CustomHatchedBar />}
|
||||
radius={4}
|
||||
/>
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const CustomHatchedBar = (
|
||||
props: React.SVGProps<SVGRectElement> & {
|
||||
dataKey?: string;
|
||||
isHatched?: boolean;
|
||||
}
|
||||
) => {
|
||||
const { fill, x, y, width, height, dataKey } = props;
|
||||
|
||||
const isHatched = props.isHatched ?? true;
|
||||
|
||||
return (
|
||||
<>
|
||||
<rect
|
||||
rx={4}
|
||||
x={x}
|
||||
y={y}
|
||||
width={width}
|
||||
height={height}
|
||||
stroke='none'
|
||||
fill={isHatched ? `url(#hatched-bar-pattern-${dataKey})` : fill}
|
||||
/>
|
||||
<defs>
|
||||
<pattern
|
||||
key={dataKey}
|
||||
id={`hatched-bar-pattern-${dataKey}`}
|
||||
x='0'
|
||||
y='0'
|
||||
width='5'
|
||||
height='5'
|
||||
patternUnits='userSpaceOnUse'
|
||||
patternTransform='rotate(-45)'
|
||||
>
|
||||
<rect width='10' height='10' opacity={0.5} fill={fill}></rect>
|
||||
<rect width='1' height='10' fill={fill}></rect>
|
||||
</pattern>
|
||||
</defs>
|
||||
</>
|
||||
);
|
||||
};
|
||||
const DottedBackgroundPattern = () => {
|
||||
return (
|
||||
<pattern
|
||||
id='default-multiple-pattern-dots'
|
||||
x='0'
|
||||
y='0'
|
||||
width='10'
|
||||
height='10'
|
||||
patternUnits='userSpaceOnUse'
|
||||
>
|
||||
<circle className='dark:text-muted/40 text-muted' cx='2' cy='2' r='1' fill='currentColor' />
|
||||
</pattern>
|
||||
);
|
||||
};
|
||||
138
src/features/overview/components/overview.tsx
Normal file
138
src/features/overview/components/overview.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import PageContainer from '@/components/layout/page-container';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardAction
|
||||
} from '@/components/ui/card';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { AreaGraph } from './area-graph';
|
||||
import { BarGraph } from './bar-graph';
|
||||
import { PieGraph } from './pie-graph';
|
||||
import { RecentSales } from './recent-sales';
|
||||
import { Icons } from '@/components/icons';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
export default function OverViewPage() {
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className='flex flex-1 flex-col space-y-2'>
|
||||
<div className='flex items-center justify-between space-y-2'>
|
||||
<h2 className='text-2xl font-bold tracking-tight'>Hi, Welcome back 👋</h2>
|
||||
<div className='hidden items-center space-x-2 md:flex'>
|
||||
<Button>Download</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Tabs defaultValue='overview' className='space-y-4'>
|
||||
<TabsList>
|
||||
<TabsTrigger value='overview'>Overview</TabsTrigger>
|
||||
<TabsTrigger value='analytics' disabled>
|
||||
Analytics
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value='overview' className='space-y-4'>
|
||||
<div className='*:data-[slot=card]:from-primary/5 *:data-[slot=card]:to-card dark:*:data-[slot=card]:bg-card grid grid-cols-1 gap-4 px-4 *:data-[slot=card]:bg-gradient-to-t *:data-[slot=card]:shadow-xs lg:px-6 @xl/main:grid-cols-2 @5xl/main:grid-cols-4'>
|
||||
<Card className='@container/card'>
|
||||
<CardHeader>
|
||||
<CardDescription>Total Revenue</CardDescription>
|
||||
<CardTitle className='text-2xl font-semibold tabular-nums @[250px]/card:text-3xl'>
|
||||
$1,250.00
|
||||
</CardTitle>
|
||||
<CardAction>
|
||||
<Badge variant='outline'>
|
||||
<Icons.trendingUp />
|
||||
+12.5%
|
||||
</Badge>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardFooter className='flex-col items-start gap-1.5 text-sm'>
|
||||
<div className='line-clamp-1 flex gap-2 font-medium'>
|
||||
Trending up this month <Icons.trendingUp className='size-4' />
|
||||
</div>
|
||||
<div className='text-muted-foreground'>Visitors for the last 6 months</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
<Card className='@container/card'>
|
||||
<CardHeader>
|
||||
<CardDescription>New Customers</CardDescription>
|
||||
<CardTitle className='text-2xl font-semibold tabular-nums @[250px]/card:text-3xl'>
|
||||
1,234
|
||||
</CardTitle>
|
||||
<CardAction>
|
||||
<Badge variant='outline'>
|
||||
<Icons.trendingDown />
|
||||
-20%
|
||||
</Badge>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardFooter className='flex-col items-start gap-1.5 text-sm'>
|
||||
<div className='line-clamp-1 flex gap-2 font-medium'>
|
||||
Down 20% this period <Icons.trendingDown className='size-4' />
|
||||
</div>
|
||||
<div className='text-muted-foreground'>Acquisition needs attention</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
<Card className='@container/card'>
|
||||
<CardHeader>
|
||||
<CardDescription>Active Accounts</CardDescription>
|
||||
<CardTitle className='text-2xl font-semibold tabular-nums @[250px]/card:text-3xl'>
|
||||
45,678
|
||||
</CardTitle>
|
||||
<CardAction>
|
||||
<Badge variant='outline'>
|
||||
<Icons.trendingUp />
|
||||
+12.5%
|
||||
</Badge>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardFooter className='flex-col items-start gap-1.5 text-sm'>
|
||||
<div className='line-clamp-1 flex gap-2 font-medium'>
|
||||
Strong user retention <Icons.trendingUp className='size-4' />
|
||||
</div>
|
||||
<div className='text-muted-foreground'>Engagement exceed targets</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
<Card className='@container/card'>
|
||||
<CardHeader>
|
||||
<CardDescription>Growth Rate</CardDescription>
|
||||
<CardTitle className='text-2xl font-semibold tabular-nums @[250px]/card:text-3xl'>
|
||||
4.5%
|
||||
</CardTitle>
|
||||
<CardAction>
|
||||
<Badge variant='outline'>
|
||||
<Icons.trendingUp />
|
||||
+4.5%
|
||||
</Badge>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardFooter className='flex-col items-start gap-1.5 text-sm'>
|
||||
<div className='line-clamp-1 flex gap-2 font-medium'>
|
||||
Steady performance increase <Icons.trendingUp className='size-4' />
|
||||
</div>
|
||||
<div className='text-muted-foreground'>Meets growth projections</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-7'>
|
||||
<div className='col-span-4'>
|
||||
<BarGraph />
|
||||
</div>
|
||||
<Card className='col-span-4 md:col-span-3'>
|
||||
<RecentSales />
|
||||
</Card>
|
||||
<div className='col-span-4'>
|
||||
<AreaGraph />
|
||||
</div>
|
||||
<div className='col-span-4 md:col-span-3'>
|
||||
<PieGraph />
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
19
src/features/overview/components/pie-graph-skeleton.tsx
Normal file
19
src/features/overview/components/pie-graph-skeleton.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||
|
||||
export function PieGraphSkeleton() {
|
||||
return (
|
||||
<Card className='flex h-full flex-col'>
|
||||
<CardHeader className='items-center pb-0'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Skeleton className='h-6 w-[100px]' />
|
||||
<Skeleton className='h-5 w-[60px] rounded-full' />
|
||||
</div>
|
||||
<Skeleton className='h-4 w-[150px]' />
|
||||
</CardHeader>
|
||||
<CardContent className='flex flex-1 items-center justify-center pb-0'>
|
||||
<Skeleton className='h-[250px] w-[250px] rounded-full' />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
91
src/features/overview/components/pie-graph.tsx
Normal file
91
src/features/overview/components/pie-graph.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
'use client';
|
||||
|
||||
import { LabelList, Pie, PieChart } from 'recharts';
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
ChartConfig,
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent
|
||||
} from '@/components/ui/chart';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Icons } from '@/components/icons';
|
||||
|
||||
const chartData = [
|
||||
{ browser: 'chrome', visitors: 275, fill: 'var(--color-chrome)' },
|
||||
{ browser: 'safari', visitors: 200, fill: 'var(--color-safari)' },
|
||||
{ browser: 'firefox', visitors: 187, fill: 'var(--color-firefox)' },
|
||||
{ browser: 'edge', visitors: 173, fill: 'var(--color-edge)' },
|
||||
{ browser: 'other', visitors: 90, fill: 'var(--color-other)' }
|
||||
];
|
||||
|
||||
const chartConfig = {
|
||||
visitors: {
|
||||
label: 'Visitors'
|
||||
},
|
||||
chrome: {
|
||||
label: 'Chrome',
|
||||
color: 'var(--chart-1)'
|
||||
},
|
||||
safari: {
|
||||
label: 'Safari',
|
||||
color: 'var(--chart-2)'
|
||||
},
|
||||
firefox: {
|
||||
label: 'Firefox',
|
||||
color: 'var(--chart-3)'
|
||||
},
|
||||
edge: {
|
||||
label: 'Edge',
|
||||
color: 'var(--chart-4)'
|
||||
},
|
||||
other: {
|
||||
label: 'Other',
|
||||
color: 'var(--chart-5)'
|
||||
}
|
||||
} satisfies ChartConfig;
|
||||
|
||||
export function PieGraph() {
|
||||
return (
|
||||
<Card className='flex h-full flex-col'>
|
||||
<CardHeader className='items-center pb-0'>
|
||||
<CardTitle>
|
||||
Pie Chart
|
||||
<Badge variant='outline'>
|
||||
<Icons.trendingUp />
|
||||
+5.2%
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
<CardDescription>January - June 2024</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='flex flex-1 items-center justify-center pb-0'>
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className='[&_.recharts-text]:fill-background mx-auto aspect-square max-h-[300px] min-h-[250px]'
|
||||
>
|
||||
<PieChart>
|
||||
<ChartTooltip content={<ChartTooltipContent nameKey='visitors' hideLabel />} />
|
||||
<Pie
|
||||
data={chartData}
|
||||
innerRadius={30}
|
||||
dataKey='visitors'
|
||||
radius={10}
|
||||
cornerRadius={8}
|
||||
paddingAngle={4}
|
||||
>
|
||||
<LabelList
|
||||
dataKey='visitors'
|
||||
stroke='none'
|
||||
fontSize={12}
|
||||
fontWeight={500}
|
||||
fill='currentColor'
|
||||
formatter={(value: number) => value.toString()}
|
||||
/>
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
27
src/features/overview/components/recent-sales-skeleton.tsx
Normal file
27
src/features/overview/components/recent-sales-skeleton.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||
|
||||
export function RecentSalesSkeleton() {
|
||||
return (
|
||||
<Card className='h-full'>
|
||||
<CardHeader>
|
||||
<Skeleton className='h-6 w-[140px]' /> {/* CardTitle */}
|
||||
<Skeleton className='h-4 w-[180px]' /> {/* CardDescription */}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className='space-y-8'>
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className='flex items-center'>
|
||||
<Skeleton className='h-9 w-9 rounded-full' /> {/* Avatar */}
|
||||
<div className='ml-4 space-y-1'>
|
||||
<Skeleton className='h-4 w-[120px]' /> {/* Name */}
|
||||
<Skeleton className='h-4 w-[160px]' /> {/* Email */}
|
||||
</div>
|
||||
<Skeleton className='ml-auto h-4 w-[80px]' /> {/* Amount */}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
68
src/features/overview/components/recent-sales.tsx
Normal file
68
src/features/overview/components/recent-sales.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Card, CardHeader, CardContent, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
|
||||
const salesData = [
|
||||
{
|
||||
name: 'Olivia Martin',
|
||||
email: 'olivia.martin@email.com',
|
||||
avatar: 'https://api.slingacademy.com/public/sample-users/1.png',
|
||||
fallback: 'OM',
|
||||
amount: '+$1,999.00'
|
||||
},
|
||||
{
|
||||
name: 'Jackson Lee',
|
||||
email: 'jackson.lee@email.com',
|
||||
avatar: 'https://api.slingacademy.com/public/sample-users/2.png',
|
||||
fallback: 'JL',
|
||||
amount: '+$39.00'
|
||||
},
|
||||
{
|
||||
name: 'Isabella Nguyen',
|
||||
email: 'isabella.nguyen@email.com',
|
||||
avatar: 'https://api.slingacademy.com/public/sample-users/3.png',
|
||||
fallback: 'IN',
|
||||
amount: '+$299.00'
|
||||
},
|
||||
{
|
||||
name: 'William Kim',
|
||||
email: 'will@email.com',
|
||||
avatar: 'https://api.slingacademy.com/public/sample-users/4.png',
|
||||
fallback: 'WK',
|
||||
amount: '+$99.00'
|
||||
},
|
||||
{
|
||||
name: 'Sofia Davis',
|
||||
email: 'sofia.davis@email.com',
|
||||
avatar: 'https://api.slingacademy.com/public/sample-users/5.png',
|
||||
fallback: 'SD',
|
||||
amount: '+$39.00'
|
||||
}
|
||||
];
|
||||
|
||||
export function RecentSales() {
|
||||
return (
|
||||
<Card className='h-full'>
|
||||
<CardHeader>
|
||||
<CardTitle>Recent Sales</CardTitle>
|
||||
<CardDescription>You made 265 sales this month.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className='space-y-8'>
|
||||
{salesData.map((sale, index) => (
|
||||
<div key={index} className='flex items-center'>
|
||||
<Avatar className='h-9 w-9'>
|
||||
<AvatarImage src={sale.avatar} alt='Avatar' />
|
||||
<AvatarFallback>{sale.fallback}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className='ml-4 space-y-1'>
|
||||
<p className='text-sm leading-none font-medium'>{sale.name}</p>
|
||||
<p className='text-muted-foreground text-sm'>{sale.email}</p>
|
||||
</div>
|
||||
<div className='ml-auto font-medium'>{sale.amount}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
27
src/features/products/api/mutations.ts
Normal file
27
src/features/products/api/mutations.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { mutationOptions } from '@tanstack/react-query';
|
||||
import { getQueryClient } from '@/lib/query-client';
|
||||
import { createProduct, updateProduct, deleteProduct } from './service';
|
||||
import { productKeys } from './queries';
|
||||
import type { ProductMutationPayload } from './types';
|
||||
|
||||
export const createProductMutation = mutationOptions({
|
||||
mutationFn: (data: ProductMutationPayload) => createProduct(data),
|
||||
onSuccess: () => {
|
||||
getQueryClient().invalidateQueries({ queryKey: productKeys.all });
|
||||
}
|
||||
});
|
||||
|
||||
export const updateProductMutation = mutationOptions({
|
||||
mutationFn: ({ id, values }: { id: number; values: ProductMutationPayload }) =>
|
||||
updateProduct(id, values),
|
||||
onSuccess: () => {
|
||||
getQueryClient().invalidateQueries({ queryKey: productKeys.all });
|
||||
}
|
||||
});
|
||||
|
||||
export const deleteProductMutation = mutationOptions({
|
||||
mutationFn: (id: number) => deleteProduct(id),
|
||||
onSuccess: () => {
|
||||
getQueryClient().invalidateQueries({ queryKey: productKeys.all });
|
||||
}
|
||||
});
|
||||
23
src/features/products/api/queries.ts
Normal file
23
src/features/products/api/queries.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { queryOptions } from '@tanstack/react-query';
|
||||
import { getProducts, getProductById } from './service';
|
||||
import type { Product, ProductFilters } from './types';
|
||||
|
||||
export type { Product };
|
||||
|
||||
export const productKeys = {
|
||||
all: ['products'] as const,
|
||||
list: (filters: ProductFilters) => [...productKeys.all, 'list', filters] as const,
|
||||
detail: (id: number) => [...productKeys.all, 'detail', id] as const
|
||||
};
|
||||
|
||||
export const productsQueryOptions = (filters: ProductFilters) =>
|
||||
queryOptions({
|
||||
queryKey: productKeys.list(filters),
|
||||
queryFn: () => getProducts(filters)
|
||||
});
|
||||
|
||||
export const productByIdOptions = (id: number) =>
|
||||
queryOptions({
|
||||
queryKey: productKeys.detail(id),
|
||||
queryFn: () => getProductById(id)
|
||||
});
|
||||
43
src/features/products/api/service.ts
Normal file
43
src/features/products/api/service.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import type {
|
||||
ProductByIdResponse,
|
||||
ProductFilters,
|
||||
ProductMutationPayload,
|
||||
ProductsResponse
|
||||
} from './types';
|
||||
|
||||
export async function getProducts(filters: ProductFilters): Promise<ProductsResponse> {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
if (filters.page) searchParams.set('page', String(filters.page));
|
||||
if (filters.limit) searchParams.set('limit', String(filters.limit));
|
||||
if (filters.categories) searchParams.set('categories', filters.categories);
|
||||
if (filters.search) searchParams.set('search', filters.search);
|
||||
if (filters.sort) searchParams.set('sort', filters.sort);
|
||||
|
||||
return apiClient<ProductsResponse>(`/products?${searchParams.toString()}`);
|
||||
}
|
||||
|
||||
export async function getProductById(id: number): Promise<ProductByIdResponse> {
|
||||
return apiClient<ProductByIdResponse>(`/products/${id}`);
|
||||
}
|
||||
|
||||
export async function createProduct(data: ProductMutationPayload) {
|
||||
return apiClient<ProductByIdResponse>('/products', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateProduct(id: number, data: ProductMutationPayload) {
|
||||
return apiClient<ProductByIdResponse>(`/products/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteProduct(id: number) {
|
||||
return apiClient<{ success: boolean; message: string }>(`/products/${id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
}
|
||||
44
src/features/products/api/types.ts
Normal file
44
src/features/products/api/types.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
export interface Product {
|
||||
id: number;
|
||||
organization_id: string;
|
||||
photo_url: string;
|
||||
name: string;
|
||||
description: string;
|
||||
created_at: string;
|
||||
price: number;
|
||||
category: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ProductFilters {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
categories?: string;
|
||||
search?: string;
|
||||
sort?: string;
|
||||
}
|
||||
|
||||
export interface ProductsResponse {
|
||||
success: boolean;
|
||||
time: string;
|
||||
message: string;
|
||||
total_products: number;
|
||||
offset: number;
|
||||
limit: number;
|
||||
products: Product[];
|
||||
}
|
||||
|
||||
export interface ProductByIdResponse {
|
||||
success: boolean;
|
||||
time: string;
|
||||
message: string;
|
||||
product: Product;
|
||||
}
|
||||
|
||||
export interface ProductMutationPayload {
|
||||
name: string;
|
||||
category: string;
|
||||
price: number;
|
||||
description: string;
|
||||
photoDataUrl?: string;
|
||||
}
|
||||
165
src/features/products/components/product-form.tsx
Normal file
165
src/features/products/components/product-form.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
'use client';
|
||||
|
||||
import { useAppForm, useFormFields } from '@/components/ui/tanstack-form';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { createProductMutation, updateProductMutation } from '../api/mutations';
|
||||
import type { Product } from '../api/types';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { toast } from 'sonner';
|
||||
import * as z from 'zod';
|
||||
import { productSchema, type ProductFormValues } from '@/features/products/schemas/product';
|
||||
import { categoryOptions } from '@/features/products/constants/product-options';
|
||||
|
||||
function fileToDataUrl(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result));
|
||||
reader.onerror = () => reject(new Error('Unable to read image file'));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
export default function ProductForm({
|
||||
initialData,
|
||||
pageTitle
|
||||
}: {
|
||||
initialData: Product | null;
|
||||
pageTitle: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const isEdit = !!initialData;
|
||||
|
||||
const createMutation = useMutation({
|
||||
...createProductMutation,
|
||||
onSuccess: () => {
|
||||
toast.success('Product created successfully');
|
||||
router.push('/dashboard/product');
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Failed to create product');
|
||||
}
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
...updateProductMutation,
|
||||
onSuccess: () => {
|
||||
toast.success('Product updated successfully');
|
||||
router.push('/dashboard/product');
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Failed to update product');
|
||||
}
|
||||
});
|
||||
|
||||
const form = useAppForm({
|
||||
defaultValues: {
|
||||
image: undefined,
|
||||
name: initialData?.name ?? '',
|
||||
category: initialData?.category ?? '',
|
||||
price: initialData?.price,
|
||||
description: initialData?.description ?? ''
|
||||
} as ProductFormValues,
|
||||
validators: {
|
||||
onSubmit: productSchema
|
||||
},
|
||||
onSubmit: async ({ value }) => {
|
||||
const selectedImage = value.image?.[0];
|
||||
const photoDataUrl = selectedImage ? await fileToDataUrl(selectedImage) : undefined;
|
||||
|
||||
const payload = {
|
||||
name: value.name,
|
||||
category: value.category,
|
||||
price: value.price!,
|
||||
description: value.description,
|
||||
...(photoDataUrl ? { photoDataUrl } : {})
|
||||
};
|
||||
|
||||
if (isEdit) {
|
||||
updateMutation.mutate({ id: initialData.id, values: payload });
|
||||
} else {
|
||||
createMutation.mutate(payload);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const { FormTextField, FormSelectField, FormTextareaField, FormFileUploadField } =
|
||||
useFormFields<ProductFormValues>();
|
||||
|
||||
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-8'>
|
||||
<FormFileUploadField
|
||||
name='image'
|
||||
label='Product Image'
|
||||
description='Upload a product image'
|
||||
maxSize={5 * 1024 * 1024}
|
||||
maxFiles={4}
|
||||
/>
|
||||
|
||||
<div className='grid grid-cols-1 gap-6 md:grid-cols-2'>
|
||||
<FormTextField
|
||||
name='name'
|
||||
label='Product Name'
|
||||
required
|
||||
placeholder='Enter product name'
|
||||
validators={{
|
||||
onBlur: z.string().min(2, 'Product name must be at least 2 characters.')
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormSelectField
|
||||
name='category'
|
||||
label='Category'
|
||||
required
|
||||
options={categoryOptions}
|
||||
placeholder='Select category'
|
||||
validators={{
|
||||
onBlur: z.string().min(1, 'Please select a category')
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormTextField
|
||||
name='price'
|
||||
label='Price'
|
||||
required
|
||||
type='number'
|
||||
min={0}
|
||||
step={0.01}
|
||||
placeholder='Enter price'
|
||||
validators={{
|
||||
onBlur: z.number({ message: 'Price is required' })
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormTextareaField
|
||||
name='description'
|
||||
label='Description'
|
||||
required
|
||||
placeholder='Enter product description'
|
||||
maxLength={500}
|
||||
rows={4}
|
||||
validators={{
|
||||
onBlur: z.string().min(10, 'Description must be at least 10 characters.')
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className='flex justify-end gap-2'>
|
||||
<Button type='button' variant='outline' onClick={() => router.back()}>
|
||||
Back
|
||||
</Button>
|
||||
<form.SubmitButton>{isEdit ? 'Update Product' : 'Add Product'}</form.SubmitButton>
|
||||
</div>
|
||||
</form.Form>
|
||||
</form.AppForm>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
37
src/features/products/components/product-listing.tsx
Normal file
37
src/features/products/components/product-listing.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
|
||||
import { getQueryClient } from '@/lib/query-client';
|
||||
import { searchParamsCache } from '@/lib/searchparams';
|
||||
import { productsQueryOptions } from '../api/queries';
|
||||
import { ProductTable } from './product-tables';
|
||||
|
||||
type ProductListingPageProps = {
|
||||
canPrefetch?: boolean;
|
||||
};
|
||||
|
||||
export default function ProductListingPage({ canPrefetch = true }: ProductListingPageProps) {
|
||||
const page = searchParamsCache.get('page');
|
||||
const search = searchParamsCache.get('name');
|
||||
const pageLimit = searchParamsCache.get('perPage');
|
||||
const categories = searchParamsCache.get('category');
|
||||
const sort = searchParamsCache.get('sort');
|
||||
|
||||
const filters = {
|
||||
page,
|
||||
limit: pageLimit,
|
||||
...(search && { search }),
|
||||
...(categories && { categories }),
|
||||
...(sort && { sort })
|
||||
};
|
||||
|
||||
const queryClient = getQueryClient();
|
||||
|
||||
if (canPrefetch) {
|
||||
void queryClient.prefetchQuery(productsQueryOptions(filters));
|
||||
}
|
||||
|
||||
return (
|
||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||
<ProductTable />
|
||||
</HydrationBoundary>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
'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 { deleteProductMutation } from '../../api/mutations';
|
||||
import type { Product } from '../../api/types';
|
||||
import { Icons } from '@/components/icons';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface CellActionProps {
|
||||
data: Product;
|
||||
}
|
||||
|
||||
export function CellAction({ data }: CellActionProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
...deleteProductMutation,
|
||||
onSuccess: () => {
|
||||
toast.success('Product deleted successfully');
|
||||
setOpen(false);
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Failed to delete product');
|
||||
}
|
||||
});
|
||||
|
||||
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/product/${data.id}`)}>
|
||||
<Icons.edit className='mr-2 h-4 w-4' /> Update
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setOpen(true)}>
|
||||
<Icons.trash className='mr-2 h-4 w-4' /> Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
77
src/features/products/components/product-tables/columns.tsx
Normal file
77
src/features/products/components/product-tables/columns.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
'use client';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { DataTableColumnHeader } from '@/components/ui/table/data-table-column-header';
|
||||
import type { Product } from '../../api/types';
|
||||
import { Column, ColumnDef } from '@tanstack/react-table';
|
||||
import { Icons } from '@/components/icons';
|
||||
import { CellAction } from './cell-action';
|
||||
import { CATEGORY_OPTIONS } from './options';
|
||||
import { ProductImagePreview } from './product-image-preview';
|
||||
|
||||
export const columns: ColumnDef<Product>[] = [
|
||||
{
|
||||
accessorKey: 'photo_url',
|
||||
header: 'IMAGE',
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<ProductImagePreview
|
||||
src={row.getValue('photo_url')}
|
||||
alt={row.getValue('name')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'name',
|
||||
accessorKey: 'name',
|
||||
header: ({ column }: { column: Column<Product, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Name' />
|
||||
),
|
||||
cell: ({ cell }) => <div>{cell.getValue<Product['name']>()}</div>,
|
||||
meta: {
|
||||
label: 'Name',
|
||||
placeholder: 'Search products...',
|
||||
variant: 'text',
|
||||
icon: Icons.text
|
||||
},
|
||||
enableColumnFilter: true
|
||||
},
|
||||
{
|
||||
id: 'category',
|
||||
accessorKey: 'category',
|
||||
enableSorting: false,
|
||||
header: ({ column }: { column: Column<Product, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Category' />
|
||||
),
|
||||
cell: ({ cell }) => {
|
||||
const status = cell.getValue<Product['category']>();
|
||||
const Icon = status === 'active' ? Icons.circleCheck : Icons.xCircle;
|
||||
|
||||
return (
|
||||
<Badge variant='outline' className='capitalize'>
|
||||
<Icon />
|
||||
{status}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
enableColumnFilter: true,
|
||||
meta: {
|
||||
label: 'categories',
|
||||
variant: 'multiSelect',
|
||||
options: CATEGORY_OPTIONS
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: 'price',
|
||||
header: 'PRICE'
|
||||
},
|
||||
{
|
||||
accessorKey: 'description',
|
||||
header: 'DESCRIPTION'
|
||||
},
|
||||
|
||||
{
|
||||
id: 'actions',
|
||||
cell: ({ row }) => <CellAction data={row.original} />
|
||||
}
|
||||
];
|
||||
51
src/features/products/components/product-tables/index.tsx
Normal file
51
src/features/products/components/product-tables/index.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
'use client';
|
||||
|
||||
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 { useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { parseAsInteger, parseAsString, useQueryStates } from 'nuqs';
|
||||
import { getSortingStateParser } from '@/lib/parsers';
|
||||
import { productsQueryOptions } from '../../api/queries';
|
||||
import { columns } from './columns';
|
||||
|
||||
const columnIds = columns.map((c) => c.id).filter(Boolean) as string[];
|
||||
|
||||
export function ProductTable() {
|
||||
const [params] = useQueryStates({
|
||||
page: parseAsInteger.withDefault(1),
|
||||
perPage: parseAsInteger.withDefault(10),
|
||||
name: parseAsString,
|
||||
category: parseAsString,
|
||||
sort: getSortingStateParser(columnIds).withDefault([])
|
||||
});
|
||||
|
||||
const filters = {
|
||||
page: params.page,
|
||||
limit: params.perPage,
|
||||
...(params.name && { search: params.name }),
|
||||
...(params.category && { categories: params.category }),
|
||||
...(params.sort.length > 0 && { sort: JSON.stringify(params.sort) })
|
||||
};
|
||||
|
||||
const { data } = useSuspenseQuery(productsQueryOptions(filters));
|
||||
|
||||
const pageCount = Math.ceil(data.total_products / params.perPage);
|
||||
|
||||
const { table } = useDataTable({
|
||||
data: data.products,
|
||||
columns,
|
||||
pageCount,
|
||||
shallow: true,
|
||||
debounceMs: 500,
|
||||
initialState: {
|
||||
columnPinning: { right: ['actions'] }
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<DataTable table={table}>
|
||||
<DataTableToolbar table={table} />
|
||||
</DataTable>
|
||||
);
|
||||
}
|
||||
10
src/features/products/components/product-tables/options.tsx
Normal file
10
src/features/products/components/product-tables/options.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
export const CATEGORY_OPTIONS = [
|
||||
{ value: 'Electronics', label: 'Electronics' },
|
||||
{ value: 'Furniture', label: 'Furniture' },
|
||||
{ value: 'Clothing', label: 'Clothing' },
|
||||
{ value: 'Toys', label: 'Toys' },
|
||||
{ value: 'Groceries', label: 'Groceries' },
|
||||
{ value: 'Books', label: 'Books' },
|
||||
{ value: 'Jewelry', label: 'Jewelry' },
|
||||
{ value: 'Beauty Products', label: 'Beauty Products' }
|
||||
];
|
||||
@@ -0,0 +1,62 @@
|
||||
'use client';
|
||||
|
||||
import Image from 'next/image';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger
|
||||
} from '@/components/ui/dialog';
|
||||
|
||||
interface ProductImagePreviewProps {
|
||||
alt: string;
|
||||
src: string;
|
||||
}
|
||||
|
||||
export function ProductImagePreview({
|
||||
alt,
|
||||
src
|
||||
}: ProductImagePreviewProps) {
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<button
|
||||
type='button'
|
||||
className='group relative h-[150px] w-[150px] overflow-hidden rounded-xl border bg-muted/20 shadow-sm transition hover:shadow-md focus-visible:ring-ring cursor-zoom-in focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden'
|
||||
aria-label={`Preview image for ${alt}`}
|
||||
>
|
||||
<Image
|
||||
src={src}
|
||||
alt={alt}
|
||||
fill
|
||||
sizes='150px'
|
||||
className='object-cover transition duration-300 group-hover:scale-[1.03]'
|
||||
/>
|
||||
<div className='bg-background/70 text-foreground absolute inset-x-0 bottom-0 flex items-center justify-center py-1 text-xs font-medium opacity-0 backdrop-blur-sm transition group-hover:opacity-100'>
|
||||
Click to preview
|
||||
</div>
|
||||
</button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className='max-w-4xl border-none bg-transparent p-0 shadow-none'>
|
||||
<DialogHeader className='sr-only'>
|
||||
<DialogTitle>{alt}</DialogTitle>
|
||||
<DialogDescription>Preview product image in a larger size.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className='bg-background relative overflow-hidden rounded-2xl border shadow-xl'>
|
||||
<div className='relative aspect-square max-h-[80vh] w-full'>
|
||||
<Image
|
||||
src={src}
|
||||
alt={alt}
|
||||
fill
|
||||
sizes='(max-width: 768px) 90vw, 70vw'
|
||||
className='object-contain bg-muted/10'
|
||||
priority={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
29
src/features/products/components/product-view-page.tsx
Normal file
29
src/features/products/components/product-view-page.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
'use client';
|
||||
|
||||
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||
import type { Product } from '../api/types';
|
||||
import { notFound } from 'next/navigation';
|
||||
import ProductForm from './product-form';
|
||||
import { productByIdOptions } from '../api/queries';
|
||||
|
||||
type TProductViewPageProps = {
|
||||
productId: string;
|
||||
};
|
||||
|
||||
export default function ProductViewPage({ productId }: TProductViewPageProps) {
|
||||
if (productId === 'new') {
|
||||
return <ProductForm initialData={null} pageTitle='Create New Product' />;
|
||||
}
|
||||
|
||||
return <EditProductView productId={Number(productId)} />;
|
||||
}
|
||||
|
||||
function EditProductView({ productId }: { productId: number }) {
|
||||
const { data } = useSuspenseQuery(productByIdOptions(productId));
|
||||
|
||||
if (!data?.success || !data?.product) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return <ProductForm initialData={data.product as Product} pageTitle='Edit Product' />;
|
||||
}
|
||||
6
src/features/products/constants/product-options.ts
Normal file
6
src/features/products/constants/product-options.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export const categoryOptions = [
|
||||
{ value: 'beauty', label: 'Beauty Products' },
|
||||
{ value: 'electronics', label: 'Electronics' },
|
||||
{ value: 'home', label: 'Home & Garden' },
|
||||
{ value: 'sports', label: 'Sports & Outdoors' }
|
||||
];
|
||||
27
src/features/products/schemas/product.ts
Normal file
27
src/features/products/schemas/product.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import * as z from 'zod';
|
||||
|
||||
const MAX_FILE_SIZE = 5_000_000;
|
||||
const ACCEPTED_IMAGE_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp'];
|
||||
|
||||
export const productSchema = z.object({
|
||||
image: z
|
||||
.any()
|
||||
.optional()
|
||||
.refine((files) => !files?.length || files?.[0]?.size <= MAX_FILE_SIZE, 'Max file size is 5MB.')
|
||||
.refine(
|
||||
(files) => !files?.length || ACCEPTED_IMAGE_TYPES.includes(files?.[0]?.type),
|
||||
'.jpg, .jpeg, .png and .webp files are accepted.'
|
||||
),
|
||||
name: z.string().min(2, 'Product name must be at least 2 characters.'),
|
||||
category: z.string().min(1, 'Please select a category'),
|
||||
price: z.number({ message: 'Price is required' }),
|
||||
description: z.string().min(10, 'Description must be at least 10 characters.')
|
||||
});
|
||||
|
||||
export type ProductFormValues = {
|
||||
image?: File[];
|
||||
name: string;
|
||||
category: string;
|
||||
price: number | undefined;
|
||||
description: string;
|
||||
};
|
||||
41
src/features/profile/components/profile-view-page.tsx
Normal file
41
src/features/profile/components/profile-view-page.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { useSession } from 'next-auth/react';
|
||||
|
||||
export default function ProfileViewPage() {
|
||||
const { data: session, status } = useSession();
|
||||
|
||||
if (status === 'loading') {
|
||||
return <div className='p-4 text-sm text-muted-foreground'>Loading profile...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex w-full flex-col p-4'>
|
||||
<Card className='max-w-2xl'>
|
||||
<CardHeader>
|
||||
<CardTitle>Profile</CardTitle>
|
||||
<CardDescription>
|
||||
This app-owned profile summary replaces the old Clerk-hosted profile screen.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4 text-sm'>
|
||||
<div className='rounded-lg border p-4'>
|
||||
<div className='text-muted-foreground'>Name</div>
|
||||
<div className='font-medium'>{session?.user?.name ?? 'Unknown user'}</div>
|
||||
</div>
|
||||
<div className='rounded-lg border p-4'>
|
||||
<div className='text-muted-foreground'>Email</div>
|
||||
<div className='font-medium'>{session?.user?.email ?? 'No email available'}</div>
|
||||
</div>
|
||||
<div className='rounded-lg border p-4'>
|
||||
<div className='text-muted-foreground'>Active workspace</div>
|
||||
<div className='font-medium'>
|
||||
{session?.user?.activeOrganizationName ?? 'No active workspace'}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
27
src/features/profile/utils/form-schema.ts
Normal file
27
src/features/profile/utils/form-schema.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import * as z from 'zod';
|
||||
|
||||
export const profileSchema = z.object({
|
||||
firstname: z.string().min(3, { message: 'Product Name must be at least 3 characters' }),
|
||||
lastname: z.string().min(3, { message: 'Product Name must be at least 3 characters' }),
|
||||
email: z.string().email({ message: 'Product Name must be at least 3 characters' }),
|
||||
contactno: z.coerce.number(),
|
||||
country: z.string().min(1, { message: 'Please select a category' }),
|
||||
city: z.string().min(1, { message: 'Please select a category' }),
|
||||
// jobs array is for the dynamic fields
|
||||
jobs: z.array(
|
||||
z.object({
|
||||
jobcountry: z.string().min(1, { message: 'Please select a category' }),
|
||||
jobcity: z.string().min(1, { message: 'Please select a category' }),
|
||||
jobtitle: z.string().min(3, { message: 'Product Name must be at least 3 characters' }),
|
||||
employer: z.string().min(3, { message: 'Product Name must be at least 3 characters' }),
|
||||
startdate: z.string().refine((value) => /^\d{4}-\d{2}-\d{2}$/.test(value), {
|
||||
message: 'Start date should be in the format YYYY-MM-DD'
|
||||
}),
|
||||
enddate: z.string().refine((value) => /^\d{4}-\d{2}-\d{2}$/.test(value), {
|
||||
message: 'End date should be in the format YYYY-MM-DD'
|
||||
})
|
||||
})
|
||||
)
|
||||
});
|
||||
|
||||
export type ProfileFormValues = z.infer<typeof profileSchema>;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user