This commit is contained in:
phaichayon
2026-06-23 22:13:08 +07:00
parent 99a4087099
commit c1ecd5ea50
32 changed files with 3503 additions and 150 deletions

View File

@@ -0,0 +1,55 @@
'use client';
import { useStore } from '@tanstack/react-form';
import { FieldDescription, FieldLabel } from '@/components/ui/field';
import {
useFieldContext,
FormFieldSet,
FormField,
FormFieldError,
createFormField
} from '@/components/ui/form-context';
import { CrmCurrencyInput } from '@/features/crm/components/crm-form-controls';
interface CurrencyFieldProps {
label: string;
description?: string;
required?: boolean;
currencyLabel?: string;
placeholder?: string;
}
export function CurrencyField({
label,
description,
required,
currencyLabel,
placeholder
}: CurrencyFieldProps) {
const field = useFieldContext();
const isTouched = useStore(field.store, (s) => s.meta.isTouched);
const isValid = useStore(field.store, (s) => s.meta.isValid);
const value = useStore(field.store, (s) => s.value) as string | number | undefined;
return (
<FormFieldSet>
<FormField>
<FieldLabel htmlFor={field.name}>
{label}
{required && ' *'}
</FieldLabel>
<CrmCurrencyInput
value={value === undefined || value === null ? '' : String(value)}
onChange={(nextValue) => field.handleChange(nextValue === '' ? '' : Number(nextValue))}
currencyLabel={currencyLabel}
placeholder={placeholder}
className={isTouched && !isValid ? 'border-destructive' : undefined}
/>
{description && <FieldDescription>{description}</FieldDescription>}
</FormField>
<FormFieldError />
</FormFieldSet>
);
}
export const FormCurrencyField = createFormField(CurrencyField);

View File

@@ -1,12 +1,7 @@
'use client';
import { useStore } from '@tanstack/react-form';
import { format } from 'date-fns';
import { Calendar } from '@/components/ui/calendar';
import { FieldDescription, FieldLabel } from '@/components/ui/field';
import { Icons } from '@/components/icons';
import { Button } from '@/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import {
FormField,
FormFieldError,
@@ -14,7 +9,7 @@ import {
createFormField,
useFieldContext
} from '@/components/ui/form-context';
import { cn } from '@/lib/utils';
import { CrmDateInput } from '@/features/crm/components/crm-form-controls';
interface DatePickerFieldProps {
label: string;
@@ -28,51 +23,28 @@ export function DatePickerField({
label,
description,
required,
placeholder = 'Pick a date',
placeholder = 'DD/MM/YYYY',
disabled
}: DatePickerFieldProps) {
void disabled;
const field = useFieldContext();
const value = useStore(field.store, (s) => s.value) as string | null | undefined;
const isTouched = useStore(field.store, (s) => s.meta.isTouched);
const isValid = useStore(field.store, (s) => s.meta.isValid);
const selectedDate = value ? new Date(value) : undefined;
return (
<FormFieldSet>
<FormField>
<FieldLabel>
<FieldLabel htmlFor={field.name}>
{label}
{required && ' *'}
</FieldLabel>
<Popover>
<PopoverTrigger asChild>
<Button
type='button'
variant='outline'
className={cn(
'w-full justify-start text-left font-normal',
!value && 'text-muted-foreground'
)}
aria-invalid={isTouched && !isValid}
onBlur={field.handleBlur}
>
<Icons.calendar className='mr-2 h-4 w-4' />
{selectedDate ? format(selectedDate, 'PPP') : <span>{placeholder}</span>}
</Button>
</PopoverTrigger>
<PopoverContent className='w-auto p-0' align='start'>
<Calendar
mode='single'
selected={selectedDate}
onSelect={(date) => {
field.handleChange(date ? date.toISOString() : '');
field.handleBlur();
}}
disabled={disabled}
initialFocus
/>
</PopoverContent>
</Popover>
<CrmDateInput
value={value}
placeholder={placeholder}
onChange={(nextValue) => {
field.handleChange(nextValue);
field.handleBlur();
}}
/>
{description && <FieldDescription>{description}</FieldDescription>}
</FormField>
<FormFieldError />

View File

@@ -8,6 +8,8 @@ export { RadioGroupField } from './radio-group-field';
export { SliderField } from './slider-field';
export { FileUploadField } from './file-upload-field';
export { DatePickerField } from './date-picker-field';
export { CurrencyField } from './currency-field';
export { PercentageField } from './percentage-field';
// Composed (standalone, for direct use in forms)
export { FormTextField } from './text-field';
@@ -19,3 +21,5 @@ export { FormRadioGroupField } from './radio-group-field';
export { FormSliderField } from './slider-field';
export { FormFileUploadField } from './file-upload-field';
export { FormDatePickerField } from './date-picker-field';
export { FormCurrencyField } from './currency-field';
export { FormPercentageField } from './percentage-field';

View File

@@ -0,0 +1,52 @@
'use client';
import { useStore } from '@tanstack/react-form';
import { FieldDescription, FieldLabel } from '@/components/ui/field';
import {
useFieldContext,
FormFieldSet,
FormField,
FormFieldError,
createFormField
} from '@/components/ui/form-context';
import { CrmPercentageInput } from '@/features/crm/components/crm-form-controls';
interface PercentageFieldProps {
label: string;
description?: string;
required?: boolean;
placeholder?: string;
}
export function PercentageField({
label,
description,
required,
placeholder
}: PercentageFieldProps) {
const field = useFieldContext();
const isTouched = useStore(field.store, (s) => s.meta.isTouched);
const isValid = useStore(field.store, (s) => s.meta.isValid);
const value = useStore(field.store, (s) => s.value) as string | number | undefined;
return (
<FormFieldSet>
<FormField>
<FieldLabel htmlFor={field.name}>
{label}
{required && ' *'}
</FieldLabel>
<CrmPercentageInput
value={value === undefined || value === null ? '' : String(value)}
onChange={(nextValue) => field.handleChange(nextValue === '' ? '' : Number(nextValue))}
placeholder={placeholder}
className={isTouched && !isValid ? 'border-destructive' : undefined}
/>
{description && <FieldDescription>{description}</FieldDescription>}
</FormField>
<FormFieldError />
</FormFieldSet>
);
}
export const FormPercentageField = createFormField(PercentageField);

View File

@@ -1,7 +1,6 @@
'use client';
import { useStore } from '@tanstack/react-form';
import { Textarea } from '@/components/ui/textarea';
import { FieldDescription, FieldLabel } from '@/components/ui/field';
import {
useFieldContext,
@@ -10,6 +9,7 @@ import {
FormFieldError,
createFormField
} from '@/components/ui/form-context';
import { CrmTextarea } from '@/features/crm/components/crm-form-controls';
interface TextareaFieldProps extends Omit<
React.ComponentProps<'textarea'>,
@@ -20,6 +20,7 @@ interface TextareaFieldProps extends Omit<
required?: boolean;
maxLength?: number;
showCount?: boolean;
size?: 'sm' | 'md' | 'lg';
}
export function TextareaField({
@@ -28,6 +29,7 @@ export function TextareaField({
required,
maxLength,
showCount = !!maxLength,
size = 'md',
className,
...textareaProps
}: TextareaFieldProps) {
@@ -43,8 +45,9 @@ export function TextareaField({
{label}
{required && ' *'}
</FieldLabel>
<Textarea
<CrmTextarea
id={field.name}
size={size}
value={value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}

View File

@@ -31,7 +31,7 @@ function SelectTrigger({
data-slot='select-trigger'
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-full max-w-full min-w-0 items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:flex *:data-[slot=select-value]:min-w-0 *:data-[slot=select-value]:flex-1 *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 *:data-[slot=select-value]:truncate [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}

View File

@@ -29,6 +29,8 @@ import {
SliderField,
FileUploadField,
DatePickerField,
CurrencyField,
PercentageField,
FormTextField,
FormTextareaField,
FormSelectField,
@@ -37,7 +39,9 @@ import {
FormRadioGroupField,
FormSliderField,
FormFileUploadField,
FormDatePickerField
FormDatePickerField,
FormCurrencyField,
FormPercentageField
} from '@/components/forms/fields';
import { cn } from '@/lib/utils';
import {
@@ -152,7 +156,9 @@ const { useAppForm, withForm, withFieldGroup } = createFormHook({
RadioGroupField,
SliderField,
FileUploadField,
DatePickerField
DatePickerField,
CurrencyField,
PercentageField
},
formComponents: {
// Layout & actions
@@ -173,7 +179,9 @@ const { useAppForm, withForm, withFieldGroup } = createFormHook({
RadioGroupField: FormRadioGroupField,
SliderField: FormSliderField,
FileUploadField: FormFileUploadField,
DatePickerField: FormDatePickerField
DatePickerField: FormDatePickerField,
CurrencyField: FormCurrencyField,
PercentageField: FormPercentageField
}
});
@@ -208,7 +216,9 @@ function useFormFields<TValues extends Record<string, unknown>>() {
FormRadioGroupField: FormRadioGroupField as unknown as Typed<typeof FormRadioGroupField>,
FormSliderField: FormSliderField as unknown as Typed<typeof FormSliderField>,
FormFileUploadField: FormFileUploadField as unknown as Typed<typeof FormFileUploadField>,
FormDatePickerField: FormDatePickerField as unknown as Typed<typeof FormDatePickerField>
FormDatePickerField: FormDatePickerField as unknown as Typed<typeof FormDatePickerField>,
FormCurrencyField: FormCurrencyField as unknown as Typed<typeof FormCurrencyField>,
FormPercentageField: FormPercentageField as unknown as Typed<typeof FormPercentageField>
};
}

View File

@@ -0,0 +1,217 @@
'use client';
import * as React from 'react';
import { Calendar } from '@/components/ui/calendar';
import { Icons } from '@/components/icons';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
InputGroup,
InputGroupAddon,
InputGroupInput
} from '@/components/ui/input-group';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Textarea } from '@/components/ui/textarea';
import { cn } from '@/lib/utils';
import { formatCrmDate, formatCrmNumber, sanitizeDecimalInput } from '../shared/formats';
export const CRM_TEXTAREA_SIZE_CLASS = {
sm: 'min-h-20',
md: 'min-h-28',
lg: 'min-h-40'
} as const;
function parseDateValue(value: string | null | undefined) {
if (!value) {
return undefined;
}
const [year, month, day] = value.split('-').map(Number);
if (!year || !month || !day) {
return undefined;
}
return new Date(year, month - 1, day);
}
function toDateValue(date?: Date) {
if (!date) {
return '';
}
return [
String(date.getFullYear()),
String(date.getMonth() + 1).padStart(2, '0'),
String(date.getDate()).padStart(2, '0')
].join('-');
}
export function CrmDateInput({
value,
onChange,
placeholder = 'DD/MM/YYYY',
className
}: {
value?: string | null;
onChange: (value: string) => void;
placeholder?: string;
className?: string;
}) {
const selectedDate = parseDateValue(value);
return (
<Popover>
<PopoverTrigger asChild>
<Button
type='button'
variant='outline'
className={cn(
'w-full justify-start overflow-hidden text-left font-normal',
!value && 'text-muted-foreground',
className
)}
>
<Icons.calendar className='mr-2 h-4 w-4 shrink-0' />
<span className='truncate'>{value ? formatCrmDate(value) : placeholder}</span>
</Button>
</PopoverTrigger>
<PopoverContent className='w-auto p-0' align='start'>
<Calendar
mode='single'
selected={selectedDate}
onSelect={(date) => onChange(toDateValue(date))}
initialFocus
/>
</PopoverContent>
</Popover>
);
}
function useFormattedNumericValue(value: string | number | null | undefined, options?: Intl.NumberFormatOptions) {
const [isFocused, setIsFocused] = React.useState(false);
const displayValue = React.useMemo(() => {
if (value === '' || value === null || value === undefined) {
return '';
}
return isFocused ? String(value).replaceAll(',', '') : formatCrmNumber(value, options);
}, [isFocused, options, value]);
return {
displayValue,
isFocused,
onFocus: () => setIsFocused(true),
onBlur: () => setIsFocused(false)
};
}
export function CrmNumberInput({
value,
onChange,
min,
max,
step,
placeholder,
className
}: {
value: string;
onChange: (value: string) => void;
min?: number;
max?: number;
step?: number | string;
placeholder?: string;
className?: string;
}) {
return (
<Input
type='number'
inputMode='decimal'
value={value}
min={min}
max={max}
step={step}
placeholder={placeholder}
className={cn('w-full', className)}
onChange={(event) => onChange(event.target.value)}
/>
);
}
export function CrmCurrencyInput({
value,
onChange,
currencyLabel = 'THB',
placeholder = '0.00',
className
}: {
value: string;
onChange: (value: string) => void;
currencyLabel?: string;
placeholder?: string;
className?: string;
}) {
const { displayValue, onBlur, onFocus } = useFormattedNumericValue(value, {
minimumFractionDigits: value ? 2 : 0,
maximumFractionDigits: 2
});
return (
<InputGroup className={className}>
<InputGroupAddon>{currencyLabel}</InputGroupAddon>
<InputGroupInput
type='text'
inputMode='decimal'
placeholder={placeholder}
value={displayValue}
onFocus={onFocus}
onBlur={onBlur}
onChange={(event) => onChange(sanitizeDecimalInput(event.target.value))}
/>
</InputGroup>
);
}
export function CrmPercentageInput({
value,
onChange,
placeholder = '0',
className
}: {
value: string;
onChange: (value: string) => void;
placeholder?: string;
className?: string;
}) {
return (
<InputGroup className={className}>
<InputGroupInput
type='number'
inputMode='decimal'
min={0}
max={100}
step='0.01'
placeholder={placeholder}
value={value}
onChange={(event) => onChange(event.target.value)}
/>
<InputGroupAddon align='inline-end'>%</InputGroupAddon>
</InputGroup>
);
}
export function CrmTextarea({
size = 'md',
className,
...props
}: React.ComponentProps<typeof Textarea> & {
size?: keyof typeof CRM_TEXTAREA_SIZE_CLASS;
}) {
return (
<Textarea
className={cn(CRM_TEXTAREA_SIZE_CLASS[size], 'resize-y', className)}
{...props}
/>
);
}

View File

@@ -127,6 +127,7 @@ export function ContactFormSheet({
<div className='md:col-span-2'>
<FormTextareaField
name='notes'
size='md'
label='หมายเหตุ'
placeholder='รูปแบบการติดต่อที่เหมาะสม หมายเหตุการประสานงาน หรือบริบทการประชุม'
/>

View File

@@ -20,7 +20,7 @@ import {
SelectTrigger,
SelectValue
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { CrmTextarea } from '@/features/crm/components/crm-form-controls';
import {
shareCustomerContactMutation,
unshareCustomerContactMutation
@@ -131,10 +131,11 @@ export function ContactShareSheet({
))}
</SelectContent>
</Select>
<Textarea
<CrmTextarea
value={remark}
onChange={(event) => setRemark(event.target.value)}
placeholder='Remark'
size='md'
/>
</div>
) : null}

View File

@@ -235,6 +235,7 @@ export function CustomerFormSheet({
<div className='md:col-span-2'>
<FormTextareaField
name='notes'
size='md'
label='หมายเหตุ'
placeholder='หมายเหตุภายในเกี่ยวกับความสัมพันธ์กับลูกค้ารายนี้'
/>

View File

@@ -21,7 +21,7 @@ import {
SelectTrigger,
SelectValue
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { CrmTextarea } from '@/features/crm/components/crm-form-controls';
import { assignCustomerOwnerMutation, clearCustomerOwnerMutation } from '../api/mutations';
import { customerByIdOptions } from '../api/queries';
import type { CustomerReferenceData } from '../api/types';
@@ -156,10 +156,11 @@ export function CustomerOwnerCard({
))}
</SelectContent>
</Select>
<Textarea
<CrmTextarea
value={remark}
onChange={(event) => setRemark(event.target.value)}
placeholder='Remark'
size='md'
/>
</div>
<DialogFooter>

View File

@@ -5,7 +5,6 @@ import Link from 'next/link';
import { parseAsString, useQueryStates } from 'nuqs';
import { Icons } from '@/components/icons';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
@@ -13,6 +12,7 @@ import {
SelectTrigger,
SelectValue
} from '@/components/ui/select';
import { CrmDateInput } from '@/features/crm/components/crm-form-controls';
import type { CrmDashboardReferenceData } from '../api/types';
export function DashboardFilters({
@@ -47,15 +47,13 @@ export function DashboardFilters({
return (
<div className='flex flex-col gap-4 rounded-xl border bg-card p-4'>
<div className='grid gap-3 md:grid-cols-2 xl:grid-cols-6'>
<Input
type='date'
<CrmDateInput
value={params.dateFrom ?? ''}
onChange={(event) => void setParams({ dateFrom: event.target.value || null })}
onChange={(value) => void setParams({ dateFrom: value || null })}
/>
<Input
type='date'
<CrmDateInput
value={params.dateTo ?? ''}
onChange={(event) => void setParams({ dateTo: event.target.value || null })}
onChange={(value) => void setParams({ dateTo: value || null })}
/>
<Select
value={params.branch ?? '__all__'}

View File

@@ -1,15 +1,15 @@
'use client';
"use client";
import { useMemo } from 'react';
import { parseAsInteger, parseAsString, useQueryStates } from 'nuqs';
import { useSuspenseQuery } from '@tanstack/react-query';
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 { enquiriesQueryOptions } from '../api/queries';
import type { EnquiryReferenceData } from '../api/types';
import { getEnquiryColumns } from './enquiry-columns';
import { useMemo } from "react";
import { parseAsInteger, parseAsString, useQueryStates } from "nuqs";
import { useSuspenseQuery } from "@tanstack/react-query";
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 { enquiriesQueryOptions } from "../api/queries";
import type { EnquiryReferenceData } from "../api/types";
import { getEnquiryColumns } from "./enquiry-columns";
export function EnquiriesTable({
referenceData,
@@ -17,20 +17,30 @@ export function EnquiriesTable({
canUpdate,
canDelete,
canAssign,
canReassign
canReassign,
}: {
referenceData: EnquiryReferenceData;
workspace: 'lead' | 'enquiry';
workspace: "lead" | "enquiry";
canUpdate: boolean;
canDelete: boolean;
canAssign: boolean;
canReassign: boolean;
}) {
const columns = useMemo(
() => getEnquiryColumns({ referenceData, workspace, canUpdate, canDelete, canAssign, canReassign }),
[referenceData, workspace, canUpdate, canDelete, canAssign, canReassign]
() =>
getEnquiryColumns({
referenceData,
workspace,
canUpdate,
canDelete,
canAssign,
canReassign,
}),
[referenceData, workspace, canUpdate, canDelete, canAssign, canReassign],
);
const columnIds = columns.map((column) => column.id).filter(Boolean) as string[];
const columnIds = columns
.map((column) => column.id)
.filter(Boolean) as string[];
const [params] = useQueryStates({
page: parseAsInteger.withDefault(1),
perPage: parseAsInteger.withDefault(10),
@@ -40,7 +50,7 @@ export function EnquiriesTable({
priority: parseAsString,
branch: parseAsString,
customer: parseAsString,
sort: getSortingStateParser(columnIds).withDefault([])
sort: getSortingStateParser(columnIds).withDefault([]),
});
const filters = {
@@ -53,10 +63,11 @@ export function EnquiriesTable({
...(params.priority && { priority: params.priority }),
...(params.branch && { branch: params.branch }),
...(params.customer && { customer: params.customer }),
...(params.sort.length > 0 && { sort: JSON.stringify(params.sort) })
...(params.sort.length > 0 && { sort: JSON.stringify(params.sort) }),
};
const { data } = useSuspenseQuery(enquiriesQueryOptions(filters));
console.log("data", data);
const pageCount = Math.ceil(data.totalItems / params.perPage);
const { table } = useDataTable({
data: data.items,
@@ -65,8 +76,8 @@ export function EnquiriesTable({
shallow: true,
debounceMs: 500,
initialState: {
columnPinning: { right: ['actions'] }
}
columnPinning: { right: ["actions"] },
},
});
return (

View File

@@ -81,8 +81,15 @@ export function EnquiryFormSheet({
);
const [selectedCustomerId, setSelectedCustomerId] = useState(defaultValues.customerId);
const [projectParties, setProjectParties] = useState<ProjectPartyEditorItem[]>([]);
const { FormTextField, FormTextareaField, FormSelectField, FormSwitchField } =
useFormFields<EnquiryFormValues>();
const {
FormTextField,
FormTextareaField,
FormSelectField,
FormSwitchField,
FormDatePickerField,
FormCurrencyField,
FormPercentageField
} = useFormFields<EnquiryFormValues>();
const projectPartiesQuery = useQuery({
...enquiryProjectPartiesOptions(enquiry?.id ?? ''),
enabled: open && !!enquiry?.id
@@ -354,22 +361,26 @@ export function EnquiryFormSheet({
label: item.label
}))}
/>
<FormTextField
<FormCurrencyField
name='estimatedValue'
label='มูลค่าประมาณการ'
type='number'
currencyLabel='THB'
placeholder='2500000'
/>
<FormTextField name='chancePercent' label='โอกาสปิดการขาย %' type='number' placeholder='60' />
<FormTextField
<FormPercentageField
name='chancePercent'
label='โอกาสปิดการขาย %'
placeholder='60'
/>
<FormDatePickerField
name='expectedCloseDate'
label='วันที่คาดว่าจะปิดการขาย'
placeholder='YYYY-MM-DD'
/>
<FormTextField name='competitor' label='คู่แข่ง' placeholder='Other vendor name' />
<div className='md:col-span-2'>
<FormTextareaField
name='description'
size='md'
label='รายละเอียด'
placeholder='สรุปภาพรวมของลีดหรือโอกาสขาย'
/>
@@ -377,6 +388,7 @@ export function EnquiryFormSheet({
<div className='md:col-span-2'>
<FormTextareaField
name='requirement'
size='md'
label='ความต้องการ'
placeholder='ความต้องการเชิงเทคนิคหรือเชิงพาณิชย์'
/>
@@ -384,6 +396,7 @@ export function EnquiryFormSheet({
<div className='md:col-span-2'>
<FormTextareaField
name='notes'
size='md'
label='หมายเหตุ'
placeholder='หมายเหตุภายใน อุปสรรค หรือข้อมูลประกอบการส่งต่องาน'
/>

View File

@@ -25,7 +25,11 @@ import {
SelectTrigger,
SelectValue
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import {
CrmCurrencyInput,
CrmDateInput,
CrmTextarea
} from '@/features/crm/components/crm-form-controls';
import {
invalidateEnquiryMutationQueries,
markEnquiryLostMutation,
@@ -323,12 +327,16 @@ export function EnquiryOutcomeCard({
</div>
<div className='space-y-2'>
<Label htmlFor='po-date'>PO Date</Label>
<Input id='po-date' type='date' value={poDate} onChange={(e) => setPoDate(e.target.value)} />
<CrmDateInput value={poDate} onChange={setPoDate} className='w-full' />
</div>
<div className='grid gap-4 md:grid-cols-2'>
<div className='space-y-2'>
<Label htmlFor='po-amount'>PO Amount</Label>
<Input id='po-amount' value={poAmount} onChange={(e) => setPoAmount(e.target.value)} />
<CrmCurrencyInput
value={poAmount}
onChange={setPoAmount}
currencyLabel={poCurrency}
/>
</div>
<div className='space-y-2'>
<Label>PO Currency</Label>
@@ -346,7 +354,12 @@ export function EnquiryOutcomeCard({
</div>
<div className='space-y-2'>
<Label htmlFor='won-remark'>Remark</Label>
<Textarea id='won-remark' value={wonRemark} onChange={(e) => setWonRemark(e.target.value)} />
<CrmTextarea
id='won-remark'
value={wonRemark}
onChange={(e) => setWonRemark(e.target.value)}
size='md'
/>
</div>
</div>
<DialogFooter>
@@ -406,7 +419,12 @@ export function EnquiryOutcomeCard({
</div>
<div className='space-y-2'>
<Label htmlFor='lost-remark'>Lost Remark</Label>
<Textarea id='lost-remark' value={lostRemark} onChange={(e) => setLostRemark(e.target.value)} />
<CrmTextarea
id='lost-remark'
value={lostRemark}
onChange={(e) => setLostRemark(e.target.value)}
size='md'
/>
</div>
</div>
<DialogFooter>
@@ -441,10 +459,11 @@ export function EnquiryOutcomeCard({
</DialogHeader>
<div className='space-y-2 py-2'>
<Label htmlFor='reopen-reason'>Reopen Reason</Label>
<Textarea
<CrmTextarea
id='reopen-reason'
value={reopenReason}
onChange={(e) => setReopenReason(e.target.value)}
size='md'
/>
</div>
<DialogFooter>

View File

@@ -58,7 +58,8 @@ export function FollowupFormSheet({
}) {
const isEdit = !!followup;
const defaultValues = useMemo(() => toDefaultValues(followup), [followup]);
const { FormTextField, FormTextareaField } = useFormFields<EnquiryFollowupFormValues>();
const { FormTextField, FormTextareaField, FormDatePickerField } =
useFormFields<EnquiryFollowupFormValues>();
const contactOptions = referenceData.contacts.filter((item) => item.customerId === customerId);
const createMutation = useMutation({
@@ -136,11 +137,10 @@ export function FollowupFormSheet({
<div className='flex-1 overflow-auto'>
<form.AppForm>
<form.Form id='enquiry-followup-form' className='grid gap-4 md:grid-cols-2'>
<FormTextField
<FormDatePickerField
name='followupDate'
label='Follow-up Date'
required
placeholder='YYYY-MM-DD'
/>
<form.AppField
name='followupType'
@@ -204,10 +204,9 @@ export function FollowupFormSheet({
</field.FieldSet>
)}
/>
<FormTextField
<FormDatePickerField
name='nextFollowupDate'
label='Next Follow-up Date'
placeholder='YYYY-MM-DD'
/>
<div className='md:col-span-2'>
<FormTextField
@@ -219,6 +218,7 @@ export function FollowupFormSheet({
<div className='md:col-span-2'>
<FormTextareaField
name='outcome'
size='md'
label='Outcome'
placeholder='Customer asked for revised dimensions and a site survey'
/>
@@ -226,6 +226,7 @@ export function FollowupFormSheet({
<div className='md:col-span-2'>
<FormTextareaField
name='notes'
size='md'
label='Notes'
placeholder='Internal notes from the follow-up session'
/>

View File

@@ -6,7 +6,7 @@ import { toast } from 'sonner';
import { Icons } from '@/components/icons';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Textarea } from '@/components/ui/textarea';
import { CrmTextarea } from '@/features/crm/components/crm-form-controls';
import {
approvalByIdOptions,
approvalsQueryOptions
@@ -80,11 +80,11 @@ export function QuotationApprovalTab({
{canSubmitNow ? (
<div className='space-y-3 rounded-lg border p-4'>
<div className='text-sm font-medium'>Submit quotation into approval flow</div>
<Textarea
<CrmTextarea
value={remark}
onChange={(event) => setRemark(event.target.value)}
placeholder='Optional remark for approvers'
rows={3}
size='md'
/>
<Button
isLoading={submitApproval.isPending}

View File

@@ -33,7 +33,13 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import {
CrmCurrencyInput,
CrmDateInput,
CrmNumberInput,
CrmPercentageInput,
CrmTextarea,
} from "@/features/crm/components/crm-form-controls";
import {
createQuotationAttachmentMutation,
createQuotationCustomerMutation,
@@ -168,12 +174,11 @@ function ItemDialog({
placeholder="Description"
/>
<div className="grid gap-4 md:grid-cols-2">
<Input
<CrmNumberInput
value={values.quantity}
onChange={(e) =>
setValues((s) => ({ ...s, quantity: e.target.value }))
onChange={(value) =>
setValues((s) => ({ ...s, quantity: value }))
}
type="number"
placeholder="Quantity"
/>
<Select
@@ -199,21 +204,21 @@ function ItemDialog({
</Select>
</div>
<div className="grid gap-4 md:grid-cols-2">
<Input
<CrmCurrencyInput
value={values.unitPrice}
onChange={(e) =>
setValues((s) => ({ ...s, unitPrice: e.target.value }))
onChange={(value) =>
setValues((s) => ({ ...s, unitPrice: value }))
}
type="number"
placeholder="Unit price"
currencyLabel="THB"
/>
<Input
<CrmCurrencyInput
value={values.discount}
onChange={(e) =>
setValues((s) => ({ ...s, discount: e.target.value }))
onChange={(value) =>
setValues((s) => ({ ...s, discount: value }))
}
type="number"
placeholder="Discount"
currencyLabel="THB"
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
@@ -238,21 +243,21 @@ function ItemDialog({
))}
</SelectContent>
</Select>
<Input
<CrmPercentageInput
value={values.taxRate}
onChange={(e) =>
setValues((s) => ({ ...s, taxRate: e.target.value }))
onChange={(value) =>
setValues((s) => ({ ...s, taxRate: value }))
}
type="number"
placeholder="Tax rate %"
/>
</div>
<Textarea
<CrmTextarea
value={values.notes}
onChange={(e) =>
setValues((s) => ({ ...s, notes: e.target.value }))
}
placeholder="Notes"
size="md"
/>
</div>
<DialogFooter>
@@ -418,11 +423,11 @@ function TopicDialog({
onChange={(e) => setTitle(e.target.value)}
placeholder="Topic title"
/>
<Textarea
<CrmTextarea
value={itemsText}
onChange={(e) => setItemsText(e.target.value)}
placeholder="One line per topic item"
rows={6}
size="lg"
/>
</div>
<DialogFooter>
@@ -500,12 +505,12 @@ function FollowupDialog({
</DialogDescription>
</DialogHeader>
<div className="grid gap-4">
<Input
type="date"
<CrmDateInput
value={values.followupDate}
onChange={(e) =>
setValues((s) => ({ ...s, followupDate: e.target.value }))
setValues((s) => ({ ...s, followupDate: e }))
}
className="w-full"
/>
<Select
value={values.followupType}
@@ -552,12 +557,12 @@ function FollowupDialog({
}
placeholder="Outcome"
/>
<Input
type="date"
<CrmDateInput
value={values.nextFollowupDate}
onChange={(e) =>
setValues((s) => ({ ...s, nextFollowupDate: e.target.value }))
setValues((s) => ({ ...s, nextFollowupDate: e }))
}
className="w-full"
/>
<Input
value={values.nextAction}
@@ -566,12 +571,13 @@ function FollowupDialog({
}
placeholder="Next action"
/>
<Textarea
<CrmTextarea
value={values.notes}
onChange={(e) =>
setValues((s) => ({ ...s, notes: e.target.value }))
}
placeholder="Notes"
size="md"
/>
</div>
<DialogFooter>
@@ -668,21 +674,21 @@ function AttachmentDialog({
}
placeholder="application/pdf"
/>
<Input
<CrmNumberInput
value={values.fileSize}
onChange={(e) =>
setValues((s) => ({ ...s, fileSize: e.target.value }))
onChange={(value) =>
setValues((s) => ({ ...s, fileSize: value }))
}
type="number"
placeholder="File size bytes"
/>
</div>
<Textarea
<CrmTextarea
value={values.description}
onChange={(e) =>
setValues((s) => ({ ...s, description: e.target.value }))
}
placeholder="Description"
size="md"
/>
</div>
<DialogFooter>

View File

@@ -10,6 +10,13 @@ import {
ProjectPartiesEditor,
type ProjectPartyEditorItem
} from '@/features/crm/components/project-parties-editor';
import {
CrmCurrencyInput,
CrmDateInput,
CrmNumberInput,
CrmPercentageInput,
CrmTextarea
} from '@/features/crm/components/crm-form-controls';
import { Input } from '@/components/ui/input';
import {
Select,
@@ -27,7 +34,6 @@ import {
SheetTitle
} from '@/components/ui/sheet';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import { quotationCustomersOptions } from '../api/queries';
import { createQuotationMutation, updateQuotationMutation } from '../api/mutations';
import type { QuotationMutationPayload, QuotationRecord, QuotationReferenceData } from '../api/types';
@@ -99,16 +105,21 @@ function toFormState(
function Field({
label,
required = false,
children,
className
}: {
label: string;
required?: boolean;
children: React.ReactNode;
className?: string;
}) {
return (
<div className={className}>
<div className='mb-2 text-sm font-medium'>{label}</div>
<div className='mb-2 text-sm font-medium'>
{label}
{required ? ' *' : ''}
</div>
{children}
</div>
);
@@ -308,7 +319,7 @@ export function QuotationFormSheet({
</Select>
</Field>
<Field label='Quotation Type'>
<Field label='Quotation Type' required>
<Select value={state.quotationType} onValueChange={(value) => setField('quotationType', value)}>
<SelectTrigger>
<SelectValue placeholder='Select type' />
@@ -323,12 +334,12 @@ export function QuotationFormSheet({
</Select>
</Field>
<Field label='Quotation Date'>
<Input type='date' value={state.quotationDate} onChange={(e) => setField('quotationDate', e.target.value)} />
<Field label='Quotation Date' required>
<CrmDateInput value={state.quotationDate} onChange={(value) => setField('quotationDate', value)} />
</Field>
<Field label='Valid Until'>
<Input type='date' value={state.validUntil} onChange={(e) => setField('validUntil', e.target.value)} />
<CrmDateInput value={state.validUntil} onChange={(value) => setField('validUntil', value)} />
</Field>
<Field label='Branch'>
@@ -378,7 +389,7 @@ export function QuotationFormSheet({
</Field>
<Field label='Exchange Rate'>
<Input value={state.exchangeRate} onChange={(e) => setField('exchangeRate', e.target.value)} type='number' step='0.0001' />
<CrmNumberInput value={state.exchangeRate} onChange={(value) => setField('exchangeRate', value)} step='0.0001' />
</Field>
<Field label='Project Name'>
@@ -414,11 +425,11 @@ export function QuotationFormSheet({
</Field>
<Field label='Chance %'>
<Input value={state.chancePercent} onChange={(e) => setField('chancePercent', e.target.value)} type='number' min='0' max='100' />
<CrmPercentageInput value={state.chancePercent} onChange={(value) => setField('chancePercent', value)} />
</Field>
<Field label='Discount'>
<Input value={state.discount} onChange={(e) => setField('discount', e.target.value)} type='number' step='0.01' />
<CrmCurrencyInput value={state.discount} onChange={(value) => setField('discount', value)} currencyLabel='THB' />
</Field>
<Field label='Discount Type'>
@@ -438,7 +449,7 @@ export function QuotationFormSheet({
</Field>
<Field label='Tax Rate %'>
<Input value={state.taxRate} onChange={(e) => setField('taxRate', e.target.value)} type='number' step='0.01' />
<CrmPercentageInput value={state.taxRate} onChange={(value) => setField('taxRate', value)} />
</Field>
<Field label='Sent Via'>
@@ -472,13 +483,13 @@ export function QuotationFormSheet({
<div className='md:col-span-2'>
<Field label='Notes'>
<Textarea value={state.notes} onChange={(e) => setField('notes', e.target.value)} placeholder='Internal notes' />
<CrmTextarea value={state.notes} onChange={(e) => setField('notes', e.target.value)} placeholder='Internal notes' size='md' />
</Field>
</div>
<div className='md:col-span-2'>
<Field label='Revision Remark'>
<Textarea value={state.revisionRemark} onChange={(e) => setField('revisionRemark', e.target.value)} placeholder='Revision note or approval context' />
<CrmTextarea value={state.revisionRemark} onChange={(e) => setField('revisionRemark', e.target.value)} placeholder='Revision note or approval context' size='md' />
</Field>
</div>

View File

@@ -5,7 +5,6 @@ import Link from 'next/link';
import { parseAsString, useQueryStates } from 'nuqs';
import { Icons } from '@/components/icons';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
@@ -13,6 +12,7 @@ import {
SelectTrigger,
SelectValue
} from '@/components/ui/select';
import { CrmDateInput } from '@/features/crm/components/crm-form-controls';
import type { CrmReportFilterMetadata } from '../api/types';
type FilterKey =
@@ -92,17 +92,15 @@ export function ReportFilterBar({
<div className='grid gap-3 md:grid-cols-2 xl:grid-cols-6'>
{filterKeys.includes('dateFrom') ? (
<Input
type='date'
<CrmDateInput
value={params.dateFrom ?? ''}
onChange={(event) => void setParams({ dateFrom: event.target.value || null })}
onChange={(value) => void setParams({ dateFrom: value || null })}
/>
) : null}
{filterKeys.includes('dateTo') ? (
<Input
type='date'
<CrmDateInput
value={params.dateTo ?? ''}
onChange={(event) => void setParams({ dateTo: event.target.value || null })}
onChange={(value) => void setParams({ dateTo: value || null })}
/>
) : null}
{(

View File

@@ -0,0 +1,47 @@
import { format, isValid, parse, parseISO } from 'date-fns';
function coerceDate(value: string | Date | null | undefined) {
if (!value) {
return null;
}
if (value instanceof Date) {
return isValid(value) ? value : null;
}
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
const parsed = parse(value, 'yyyy-MM-dd', new Date());
return isValid(parsed) ? parsed : null;
}
const isoParsed = parseISO(value);
return isValid(isoParsed) ? isoParsed : null;
}
export function formatCrmDate(value: string | Date | null | undefined) {
const date = coerceDate(value);
return date ? format(date, 'dd/MM/yyyy') : '';
}
export function formatCrmNumber(value: number | string | null | undefined, options?: Intl.NumberFormatOptions) {
if (value === '' || value === null || value === undefined) {
return '';
}
const parsed = typeof value === 'number' ? value : Number(String(value).replaceAll(',', ''));
if (Number.isNaN(parsed)) {
return '';
}
return new Intl.NumberFormat('en-US', options).format(parsed);
}
export function sanitizeDecimalInput(value: string) {
const normalized = value.replaceAll(',', '').replace(/[^\d.-]/g, '');
const [head = '', ...tail] = normalized.split('.');
const integerPart = head.replace(/(?!^)-/g, '');
const decimalPart = tail.join('');
return decimalPart.length > 0 ? `${integerPart}.${decimalPart}` : integerPart;
}