commit
This commit is contained in:
217
src/features/crm/components/crm-form-controls.tsx
Normal file
217
src/features/crm/components/crm-form-controls.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user