This commit is contained in:
phaichayon
2026-06-11 12:46:57 +07:00
parent 1993d88306
commit 7a390cf0df
720 changed files with 100919 additions and 0 deletions

View 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
});

View 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)
});
}

View 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'
});
}

View 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[];
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
</>
);
}

View 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} />
}
];

View 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>
);
}

View 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>
);
}

View 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;

View 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>;

View 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')
});

View 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>
);
}

View 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>
);
}

View 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">
&ldquo;Move from template scaffolding to app-owned auth and data
without tearing up the dashboard UX.&rdquo;
</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>
);
}

View 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'>
&ldquo;Own the auth boundary, own the org model, and keep your feature modules
stable.&rdquo;
</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&apos;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>
);
}

View 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>
</>
);
}

View 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' />
</>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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.'
]
}
];

View 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' }
// )
);

View 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[];
};

View 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
});

View 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)
});

View 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'
});
}
}

View 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';
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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' }
]
}
]
};

View 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;

View 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>;

View 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}%`;
}

View 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
);
}

View 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 &quot;{search}&quot;
</p>
)}
</div>
</PageContainer>
);
}

View 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)
});

View File

@@ -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>
);
}

View 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>
);
}

View 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>
);
}

View 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'
}
];

View 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'
}
]
}
];

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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)
};
};
}

View 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' }
// )
);

View 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>
);
}

View 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>
);
}

View 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' }
// )
);

View 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>
);
}

View 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>
))}
</>
);
};

View 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>
);
}

View 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>
);
};

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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 });
}
});

View 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)
});

View 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'
});
}

View 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;
}

View 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>
);
}

View 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>
);
}

View File

@@ -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>
</>
);
}

View 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} />
}
];

View 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>
);
}

View 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' }
];

View File

@@ -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>
);
}

View 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' />;
}

View 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' }
];

View 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;
};

View 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>
);
}

View 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