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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -127,6 +127,7 @@ export function ContactFormSheet({
|
||||
<div className='md:col-span-2'>
|
||||
<FormTextareaField
|
||||
name='notes'
|
||||
size='md'
|
||||
label='หมายเหตุ'
|
||||
placeholder='รูปแบบการติดต่อที่เหมาะสม หมายเหตุการประสานงาน หรือบริบทการประชุม'
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -235,6 +235,7 @@ export function CustomerFormSheet({
|
||||
<div className='md:col-span-2'>
|
||||
<FormTextareaField
|
||||
name='notes'
|
||||
size='md'
|
||||
label='หมายเหตุ'
|
||||
placeholder='หมายเหตุภายในเกี่ยวกับความสัมพันธ์กับลูกค้ารายนี้'
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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__'}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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='หมายเหตุภายใน อุปสรรค หรือข้อมูลประกอบการส่งต่องาน'
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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}
|
||||
{(
|
||||
|
||||
47
src/features/crm/shared/formats.ts
Normal file
47
src/features/crm/shared/formats.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user