added grid handle and required field checking

This commit is contained in:
suryac 2026-10-05 11:56:03 +05:30
parent 465d202458
commit 13160aecb4
14 changed files with 588 additions and 197 deletions

42
README.md Normal file
View File

@ -0,0 +1,42 @@
# Krishna Sales Mobile App
This is a Progressive Web App (Pwa) built for the Krishna Sales team.
## Version
**1.0.2**
## Sitemap
### Public Routes
- `/login` - User Authentication
### Authenticated Routes (Console)
These routes require the user to be logged in. Roles determine access.
#### Daily Logs
- `/daily` - My Daily Logs (Sales Officer, Manager, Admin)
- `/daily/:instanceId` - View specific daily log
- `/all-daily` - All Daily Logs (Manager, Admin)
- `/all-daily/:instanceId` - View specific daily log from all
#### Orders
- `/orders` - View Orders (Sales Officer, Manager, Admin)
- `/orders/:instanceId` - View specific order
#### Calls
- `/calls` - View Calls (Sales Officer, Manager, Admin)
- `/calls/:instanceId` - View specific call
#### Stores
- `/stores` - View Stores (Sales Officer, Manager, Admin)
- `/stores/:instanceId` - View specific store
#### Reports & Analytics
- `/analytics` - Analytics Dashboard (Sales Officer, Manager, Admin)
- `/sales-report` - Daily Sales Report (Manager, Admin)
#### Admin
- `/admin/users` - Manage Users (Manager, Admin)
- `/admin/datasets` - Manage Datasets (Admin Only)
- `/admin/datasets/:id` - Manage Dataset Items (Admin Only)

View File

@ -1,7 +1,7 @@
{ {
"name": "krishna-sales-pwa", "name": "krishna-sales-pwa",
"private": true, "private": true,
"version": "0.0.0", "version": "1.0.2",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View File

@ -82,13 +82,17 @@ export class ZinoClient {
} }
if (!res.ok) { if (!res.ok) {
let message = res.statusText; let message = res.statusText;
let field_errors: any[] | undefined;
let code: string | undefined;
try { try {
const j = (await res.json()) as { error?: string }; const j = (await res.json()) as any;
if (j.error) message = j.error; if (j.error) message = j.error;
if (j.field_errors) field_errors = j.field_errors;
if (j.code) code = j.code;
} catch { } catch {
/* non-JSON body */ /* non-JSON body */
} }
throw { status: res.status, message } as ApiError; throw { status: res.status, message, field_errors, code } as any;
} }
if (res.status === 204) return undefined as T; if (res.status === 204) return undefined as T;
return (await res.json()) as T; return (await res.json()) as T;

View File

@ -271,5 +271,5 @@ export const HIDDEN_FORM_FIELDS = [
export const SELECT_OPTION_LIMIT = 200; export const SELECT_OPTION_LIMIT = 200;
export const PDF_MAX_SKUS_PER_CHUNK = 50; export const PDF_MAX_SKUS_PER_CHUNK = 50;
export const APP_VERSION = '1.0.1'; export const APP_VERSION = '1.0.2';
export const FORM_DRAFT_EXPIRY_MS = 30 * 60 * 1000; export const FORM_DRAFT_EXPIRY_MS = 30 * 60 * 1000;

View File

@ -203,6 +203,7 @@ export function DynamicForm({ client, activityId: initialActivityId, instanceId:
const [values, setValues] = useState<Record<string, unknown>>({}); const [values, setValues] = useState<Record<string, unknown>>({});
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null); const [submitError, setSubmitError] = useState<string | null>(null);
const [backendErrors, setBackendErrors] = useState<Record<string, string>>({});
const clickedActionRef = useRef<string | null>(null); const clickedActionRef = useRef<string | null>(null);
const hasSubmittedRef = useRef(false); const hasSubmittedRef = useRef(false);
@ -225,7 +226,59 @@ export function DynamicForm({ client, activityId: initialActivityId, instanceId:
} }
}; };
const isLocalhost = typeof window !== 'undefined' && (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1');
const handleSeedData = () => {
if (!schema) return;
const next: Record<string, unknown> = { ...values };
schema.fields.forEach(f => {
if (f.properties?.disabled || HIDDEN_FORM_FIELDS.includes(f.id)) return;
if (next[f.id] != null && next[f.id] !== '') return;
const type = f.data_type;
const lowerName = (f.name || '').toLowerCase();
if (type.startsWith('select') || type.startsWith('multiselect') || type.startsWith('radio') || type === 'wf_lookup') {
const opts = f.properties?.options || (f as any).options;
if (opts && opts.length > 0) {
next[f.id] = opts[0].value;
}
} else if (type === 'number') {
if (lowerName.includes('pincode')) {
next[f.id] = '123456';
} else {
next[f.id] = 10;
}
} else if (type.startsWith('text') || type.startsWith('string') || type.startsWith('textarea') || type.startsWith('longtext')) {
if (lowerName.includes('pincode')) {
next[f.id] = '123456';
} else {
next[f.id] = 'Test ' + f.name;
}
} else if (type.startsWith('email')) {
next[f.id] = 'test@example.com';
} else if (type.startsWith('phone')) {
next[f.id] = '9999999999';
} else if (type.startsWith('date')) {
next[f.id] = new Date().toISOString().split('T')[0];
} else if (type.startsWith('time')) {
next[f.id] = '12:00';
} else if (type.startsWith('geolocation')) {
next[f.id] = { lat: 20.5937, lng: 78.9629, accuracy: 10 };
}
});
setValues(next);
};
const handleFieldChange = (fieldId: string, newVal: unknown, rawRow?: any) => { const handleFieldChange = (fieldId: string, newVal: unknown, rawRow?: any) => {
setSubmitError(null);
setBackendErrors(prev => {
if (!prev[fieldId]) return prev;
const next = { ...prev };
delete next[fieldId];
return next;
});
setValues(prev => { setValues(prev => {
const next = { ...prev, [fieldId]: newVal }; const next = { ...prev, [fieldId]: newVal };
@ -371,10 +424,48 @@ export function DynamicForm({ client, activityId: initialActivityId, instanceId:
const actionField = fields.find(f => (f.name || '').toLowerCase() === 'action' && f.data_type === 'radio'); const actionField = fields.find(f => (f.name || '').toLowerCase() === 'action' && f.data_type === 'radio');
const normalFields = fields.filter(f => f !== actionField); const normalFields = fields.filter(f => f !== actionField);
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault();
const formElement = e.currentTarget;
if (!formElement.checkValidity()) {
const invalidElements = formElement.querySelectorAll(':invalid');
const newErrors: Record<string, string> = {};
invalidElements.forEach((el) => {
const inputEl = el as HTMLInputElement | HTMLSelectElement;
const wrapper = inputEl.closest('[id^="field-"]');
if (wrapper) {
const fieldId = wrapper.id.replace('field-', '');
let msg = 'This field is required.';
if (inputEl.validity.patternMismatch && inputEl.title) {
msg = inputEl.title;
} else if (inputEl.validity.typeMismatch || inputEl.validity.badInput) {
msg = inputEl.validationMessage || 'Please enter a valid value.';
} else if (inputEl.validity.valueMissing) {
msg = 'This field is required.';
} else if (inputEl.validationMessage) {
msg = inputEl.validationMessage;
}
newErrors[fieldId] = msg;
}
});
setBackendErrors(prev => ({ ...prev, ...newErrors }));
const firstInvalid = invalidElements[0] as HTMLElement;
if (firstInvalid) {
firstInvalid.scrollIntoView({ behavior: 'smooth', block: 'center' });
firstInvalid.focus();
}
setSubmitError(null); // Clear global submit error since we show field-specific ones
return;
}
setSubmitting(true); setSubmitting(true);
setSubmitError(null); setSubmitError(null);
setBackendErrors({});
try { try {
const payload: Record<string, unknown> = {}; const payload: Record<string, unknown> = {};
const finalValues = { ...values }; const finalValues = { ...values };
@ -386,7 +477,17 @@ export function DynamicForm({ client, activityId: initialActivityId, instanceId:
if (f.mandatory && f.data_type === 'grid') { if (f.mandatory && f.data_type === 'grid') {
const val = finalValues[f.id]; const val = finalValues[f.id];
if (!Array.isArray(val) || val.length === 0) { if (!Array.isArray(val) || val.length === 0) {
setSubmitError(`Please add at least one item to ${f.name}`); setSubmitError(null);
setBackendErrors(prev => ({ ...prev, [f.id]: `Please add at least one item to ${f.name}` }));
// Scroll to the field
setTimeout(() => {
const fieldEl = document.getElementById(`field-${f.id}`);
if (fieldEl) {
fieldEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}, 100);
setSubmitting(false); setSubmitting(false);
return; return;
} }
@ -418,24 +519,23 @@ export function DynamicForm({ client, activityId: initialActivityId, instanceId:
phone: phoneNum, phone: phoneNum,
phone_with_dial_code: `+91${phoneNum}` phone_with_dial_code: `+91${phoneNum}`
}; };
} else if ((f.data_type === 'image' || f.data_type === 'file') && Array.isArray(val) && val.length > 0 && (val[0] instanceof File || val[0] instanceof Blob)) { } else if (f.data_type === 'image' || f.data_type === 'file') {
const fileList = Array.isArray(val) ? val : [val];
const uploadedFiles = []; const uploadedFiles = [];
for (const file of val) { for (const fileItem of fileList) {
let fileToUpload = file as File; if (fileItem instanceof File || fileItem instanceof Blob) {
let fileToUpload = fileItem as File;
if (!(fileToUpload instanceof File)) { if (!(fileToUpload instanceof File)) {
fileToUpload = new File([file], (file as any).name || "image.jpg", { type: file.type || 'image/jpeg' }); fileToUpload = new File([fileItem], (fileItem as any).name || "image.jpg", { type: fileItem.type || 'image/jpeg' });
} }
const fileMeta = await client.uploadFile(fileToUpload, { activityId: currentActivityId, fieldId: f.id, instanceId: currentInstanceId }); const fileMeta = await client.uploadFile(fileToUpload, { activityId: currentActivityId, fieldId: f.id, instanceId: currentInstanceId });
uploadedFiles.push(fileMeta); uploadedFiles.push(fileMeta);
} else {
// Already uploaded (e.g., { uuid: '...' }) or just a string URL
uploadedFiles.push(fileItem);
}
} }
payload[f.id] = uploadedFiles; payload[f.id] = uploadedFiles;
} else if ((f.data_type === 'image' || f.data_type === 'file') && (val instanceof File || val instanceof Blob)) {
let fileToUpload = val as File;
if (!(fileToUpload instanceof File)) {
fileToUpload = new File([val], (val as any).name || "image.jpg", { type: val.type || 'image/jpeg' });
}
const fileMeta = await client.uploadFile(fileToUpload, { activityId: currentActivityId, fieldId: f.id, instanceId: currentInstanceId });
payload[f.id] = [fileMeta];
} else { } else {
if (f.data_type === 'grid' && Array.isArray(val)) { if (f.data_type === 'grid' && Array.isArray(val)) {
val = val.map(row => { val = val.map(row => {
@ -574,13 +674,37 @@ export function DynamicForm({ client, activityId: initialActivityId, instanceId:
} }
} catch (err: any) { } catch (err: any) {
setSubmitError(err.message || 'Failed to submit form'); setSubmitError(err.message || 'Failed to submit form');
if (err.field_errors && Array.isArray(err.field_errors)) {
const newErrors: Record<string, string> = {};
err.field_errors.forEach((fe: any) => {
if (fe.field_id && fe.message) {
let msg = fe.message;
if (msg.includes('PIN Code must be at least 100000')) {
msg = 'Please enter exactly 6 digits';
}
newErrors[fe.field_id] = msg;
}
});
setBackendErrors(newErrors);
if (err.field_errors.length > 0 && err.field_errors[0].field_id) {
const firstId = err.field_errors[0].field_id;
setTimeout(() => {
const el = document.getElementById(`field-${firstId}`);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
const inputEl = el.querySelector('input, select, textarea, button') as HTMLElement;
if (inputEl) inputEl.focus();
}
}, 100);
}
}
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
}; };
return ( return (
<form onSubmit={handleSubmit} className="flex flex-col gap-4"> <form onSubmit={handleSubmit} className="flex flex-col gap-4" noValidate>
{normalFields.map(f => { {normalFields.map(f => {
const type = f.data_type; const type = f.data_type;
const val = values[f.id]; const val = values[f.id];
@ -657,6 +781,7 @@ export function DynamicForm({ client, activityId: initialActivityId, instanceId:
onChange={(newVal) => handleFieldChange(f.id, newVal)} onChange={(newVal) => handleFieldChange(f.id, newVal)}
isActivityPotentialMining={currentActivityId === ORDER_BOOKING.activities.POTENTIAL_MINING.uid} isActivityPotentialMining={currentActivityId === ORDER_BOOKING.activities.POTENTIAL_MINING.uid}
required={f.mandatory} required={f.mandatory}
error={!!backendErrors[f.id]}
/> />
); );
} }
@ -756,22 +881,33 @@ export function DynamicForm({ client, activityId: initialActivityId, instanceId:
const content = renderField(); const content = renderField();
return isDisabled ? ( return isDisabled ? (
<fieldset key={f.id} disabled className="opacity-60 pointer-events-none"> <fieldset key={f.id} id={`field-${f.id}`} disabled className="opacity-60 pointer-events-none">
{content} {content}
</fieldset> </fieldset>
) : ( ) : (
<div key={f.id}>{content}</div> <div key={f.id} id={`field-${f.id}`} className="flex flex-col gap-1">
{content}
{backendErrors[f.id] && (
<div className="text-xs font-medium text-ruby-600 mt-0.5">{backendErrors[f.id]}</div>
)}
</div>
); );
})} })}
{submitError && <div className="text-sm text-ruby-600 mt-2">{submitError}</div>} {submitError && <div className="text-sm text-ruby-600 mt-2">{submitError}</div>}
<div className="flex items-center justify-between mt-4 pt-4 border-t border-border-subtle"> <div className="flex items-center justify-between mt-4 pt-4 border-t border-border-subtle">
<div className="flex items-center gap-2">
<Button type="button" variant="ghost" className="text-ruby-600 px-2" onClick={handleReset} disabled={submitting}> <Button type="button" variant="ghost" className="text-ruby-600 px-2" onClick={handleReset} disabled={submitting}>
Reset Draft Reset Draft
</Button> </Button>
{isLocalhost && (
<Button type="button" variant="secondary" className="px-2 text-indigo-600 border-indigo-200 bg-indigo-50 hover:bg-indigo-100" onClick={handleSeedData} disabled={submitting}>
Seed Data
</Button>
)}
</div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{onCancel && ( {onCancel && (
<Button type="button" variant="secondary" onClick={() => { <Button type="button" variant="secondary" onClick={() => {

View File

@ -76,6 +76,7 @@ export function LogVisitForm({ client, onSuccess, onCancel, onActivityChange }:
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null); const [submitError, setSubmitError] = useState<string | null>(null);
const [storeError, setStoreError] = useState<string | null>(null); const [storeError, setStoreError] = useState<string | null>(null);
const [backendErrors, setBackendErrors] = useState<Record<string, string>>({});
// 1. Fetch form schema from API (/app/434/view/form-screens) // 1. Fetch form schema from API (/app/434/view/form-screens)
useEffect(() => { useEffect(() => {
@ -181,6 +182,47 @@ export function LogVisitForm({ client, onSuccess, onCancel, onActivityChange }:
} }
}; };
const isLocalhost = typeof window !== 'undefined' && (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1');
const handleSeedData = () => {
if (!schema) return;
const next: Record<string, any> = { ...valuesRef.current };
schema.fields.forEach(f => {
const fieldId = f.id;
const lowerId = fieldId.toLowerCase();
const lowerName = f.name.toLowerCase();
const isHidden = schema?.field_defaults?.[fieldId]?.hidden === true || (f.properties as any)?.hidden === true || HIDDEN_FORM_FIELDS.includes(fieldId);
if (isHidden || lowerId === 'daily_log' || lowerId === 'route_code' || lowerId === 'field_1785225403902' || lowerId === 'field_1785311859486') return;
if (schema?.field_defaults?.[fieldId]?.disabled === true || f.properties?.disabled === true) return;
if (next[fieldId] != null && next[fieldId] !== '') return;
const type = f.data_type;
if (type === 'wf_lookup' || lowerId === 'select_store' || lowerName.includes('select store')) {
if (storeOptions.length > 0) {
next[fieldId] = storeOptions[0].value;
next['select_store'] = storeOptions[0].value;
}
} else if (type === 'number') {
next[fieldId] = lowerName.includes('pincode') ? '123456' : 10;
} else if (type.startsWith('text') || type.startsWith('string') || type.startsWith('textarea') || type.startsWith('longtext')) {
next[fieldId] = lowerName.includes('pincode') ? '123456' : 'Test ' + f.name;
} else if (type.startsWith('email')) {
next[fieldId] = 'test@example.com';
} else if (type.startsWith('phone')) {
next[fieldId] = '9999999999';
} else if (type.startsWith('date')) {
next[fieldId] = todayStr;
} else if (type.startsWith('time')) {
next[fieldId] = nowTimeStr;
}
});
setValues(next);
valuesRef.current = next;
};
// 2. Helper to fetch select_store options ONLY using updated prefilled formData // 2. Helper to fetch select_store options ONLY using updated prefilled formData
const fetchStoreOptions = useCallback(async (formDataOverride?: Record<string, any>, searchStr: string = '') => { const fetchStoreOptions = useCallback(async (formDataOverride?: Record<string, any>, searchStr: string = '') => {
setFetchingStores(true); setFetchingStores(true);
@ -328,7 +370,32 @@ export function LogVisitForm({ client, onSuccess, onCancel, onActivityChange }:
const handleFieldChange = (fieldId: string, val: any) => {
setSubmitError(null);
setBackendErrors(prev => {
if (!prev[fieldId]) return prev;
const next = { ...prev };
delete next[fieldId];
return next;
});
setValues(p => {
const next = { ...p, [fieldId]: val };
if (fieldId === 'date_of_visit') next.date_of_visit = val;
if (fieldId === 'time_of_visit') next.time_of_visit = val;
if (fieldId === 'upload_image') next.upload_image = val;
valuesRef.current = next;
return next;
});
};
const handleStoreSelect = (fieldId: string, val: string) => { const handleStoreSelect = (fieldId: string, val: string) => {
setSubmitError(null);
setBackendErrors(prev => {
if (!prev[fieldId]) return prev;
const next = { ...prev };
delete next[fieldId];
return next;
});
const selectedOpt = storeOptions.find(o => String(o.value) === String(val)); const selectedOpt = storeOptions.find(o => String(o.value) === String(val));
const rawRow = selectedOpt?._raw || {}; const rawRow = selectedOpt?._raw || {};
@ -345,11 +412,48 @@ export function LogVisitForm({ client, onSuccess, onCancel, onActivityChange }:
}); });
}; };
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault();
const formElement = e.currentTarget;
if (!formElement.checkValidity()) {
const invalidElements = formElement.querySelectorAll(':invalid');
const newErrors: Record<string, string> = {};
invalidElements.forEach((el) => {
const inputEl = el as HTMLInputElement | HTMLSelectElement;
const wrapper = inputEl.closest('[id^="field-"]');
if (wrapper) {
const fieldId = wrapper.id.replace('field-', '');
let msg = 'This field is required.';
if (inputEl.validity.patternMismatch && inputEl.title) {
msg = inputEl.title;
} else if (inputEl.validity.typeMismatch || inputEl.validity.badInput) {
msg = inputEl.validationMessage || 'Please enter a valid value.';
} else if (inputEl.validity.valueMissing) {
msg = 'This field is required.';
} else if (inputEl.validationMessage) {
msg = inputEl.validationMessage;
}
newErrors[fieldId] = msg;
}
});
setBackendErrors(prev => ({ ...prev, ...newErrors }));
const firstInvalid = invalidElements[0] as HTMLElement;
if (firstInvalid) {
firstInvalid.scrollIntoView({ behavior: 'smooth', block: 'center' });
firstInvalid.focus();
}
setSubmitError(null);
return;
}
setSubmitting(true); setSubmitting(true);
setSubmitError(null); setSubmitError(null);
setBackendErrors({});
try { try {
const validFields = schema?.fields || []; const validFields = schema?.fields || [];
@ -374,7 +478,7 @@ export function LogVisitForm({ client, onSuccess, onCancel, onActivityChange }:
// Handle image upload if present // Handle image upload if present
let uploadedFiles: any[] = []; let uploadedFiles: any[] = [];
const imgVal = valuesRef.current.upload_image; const imgVal = valuesRef.current.upload_image;
const fileList = Array.isArray(imgVal) ? imgVal : (imgVal instanceof File || imgVal instanceof Blob ? [imgVal] : []); const fileList = Array.isArray(imgVal) ? imgVal : (imgVal ? [imgVal] : []);
for (const fileItem of fileList) { for (const fileItem of fileList) {
if (fileItem instanceof File || fileItem instanceof Blob) { if (fileItem instanceof File || fileItem instanceof Blob) {
@ -413,6 +517,30 @@ export function LogVisitForm({ client, onSuccess, onCancel, onActivityChange }:
} }
} catch (err: any) { } catch (err: any) {
setSubmitError(err?.message || 'Failed to log visit.'); setSubmitError(err?.message || 'Failed to log visit.');
if (err?.field_errors && Array.isArray(err.field_errors)) {
const newErrors: Record<string, string> = {};
err.field_errors.forEach((fe: any) => {
if (fe.field_id && fe.message) {
let msg = fe.message;
if (msg.includes('PIN Code must be at least 100000')) {
msg = 'Please enter exactly 6 digits';
}
newErrors[fe.field_id] = msg;
}
});
setBackendErrors(newErrors);
if (err.field_errors.length > 0 && err.field_errors[0].field_id) {
const firstId = err.field_errors[0].field_id;
setTimeout(() => {
const el = document.getElementById(`field-${firstId}`);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
const inputEl = el.querySelector('input, select, textarea, button') as HTMLElement;
if (inputEl) inputEl.focus();
}
}, 100);
}
}
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
@ -444,7 +572,7 @@ export function LogVisitForm({ client, onSuccess, onCancel, onActivityChange }:
const fields = schema?.fields || []; const fields = schema?.fields || [];
return ( return (
<form onSubmit={handleSubmit} className="flex flex-col gap-4"> <form onSubmit={handleSubmit} className="flex flex-col gap-4" noValidate>
{fields.map(f => { {fields.map(f => {
const fieldId = f.id; const fieldId = f.id;
const lowerId = fieldId.toLowerCase(); const lowerId = fieldId.toLowerCase();
@ -469,9 +597,10 @@ export function LogVisitForm({ client, onSuccess, onCancel, onActivityChange }:
const val = values[fieldId]; const val = values[fieldId];
const isDisabled = schema?.field_defaults?.[fieldId]?.disabled === true || f.properties?.disabled === true; const isDisabled = schema?.field_defaults?.[fieldId]?.disabled === true || f.properties?.disabled === true;
const renderField = () => {
if (type === 'wf_lookup' || lowerId === 'select_store' || lowerName.includes('select store')) { if (type === 'wf_lookup' || lowerId === 'select_store' || lowerName.includes('select store')) {
return ( return (
<div key={fieldId} className="flex flex-col gap-1 relative"> <div className="flex flex-col gap-1 relative">
<Select <Select
label={f.name} label={f.name}
required={f.mandatory} required={f.mandatory}
@ -499,18 +628,11 @@ export function LogVisitForm({ client, onSuccess, onCancel, onActivityChange }:
if (type.startsWith('date')) { if (type.startsWith('date')) {
return ( return (
<DateField <DateField
key={fieldId}
label={f.name} label={f.name}
required={f.mandatory} required={f.mandatory}
disabled={isDisabled} disabled={isDisabled}
value={(val as string) || ''} value={(val as string) || ''}
onChange={v => { onChange={v => handleFieldChange(fieldId, v)}
setValues(p => {
const next = { ...p, [fieldId]: v, date_of_visit: v };
valuesRef.current = next;
return next;
});
}}
/> />
); );
} }
@ -518,18 +640,11 @@ export function LogVisitForm({ client, onSuccess, onCancel, onActivityChange }:
if (type.startsWith('time')) { if (type.startsWith('time')) {
return ( return (
<TimeField <TimeField
key={fieldId}
label={f.name} label={f.name}
required={f.mandatory} required={f.mandatory}
disabled={isDisabled} disabled={isDisabled}
value={(val as string) || ''} value={(val as string) || ''}
onChange={v => { onChange={v => handleFieldChange(fieldId, v)}
setValues(p => {
const next = { ...p, [fieldId]: v, time_of_visit: v };
valuesRef.current = next;
return next;
});
}}
/> />
); );
} }
@ -537,46 +652,51 @@ export function LogVisitForm({ client, onSuccess, onCancel, onActivityChange }:
if (type === 'image' || type === 'file') { if (type === 'image' || type === 'file') {
return ( return (
<FileInput <FileInput
key={fieldId}
label={f.name} label={f.name}
type={type} type={type}
required={f.mandatory} required={f.mandatory}
value={val || []} value={val || []}
onChange={v => { onChange={v => handleFieldChange(fieldId, v)}
setValues(p => {
const next = { ...p, [fieldId]: v, upload_image: v };
valuesRef.current = next;
return next;
});
}}
/> />
); );
} }
return ( return (
<TextField <TextField
key={fieldId}
label={f.name} label={f.name}
required={f.mandatory} required={f.mandatory}
type={type} type={type}
value={(val as string) || ''} value={(val as string) || ''}
onChange={v => { onChange={v => handleFieldChange(fieldId, v)}
setValues(p => {
const next = { ...p, [fieldId]: v };
valuesRef.current = next;
return next;
});
}}
/> />
); );
};
const content = renderField();
return (
<div key={fieldId} id={`field-${fieldId}`} className="flex flex-col gap-1">
{content}
{backendErrors[fieldId] && (
<div className="text-xs font-medium text-ruby-600 mt-0.5">{backendErrors[fieldId]}</div>
)}
</div>
);
})} })}
{submitError && <div className="text-sm text-ruby-600 mt-2">{submitError}</div>} {submitError && <div className="text-sm text-ruby-600 mt-2">{submitError}</div>}
<div className="flex items-center justify-between mt-4 pt-4 border-t border-border-subtle"> <div className="flex items-center justify-between mt-4 pt-4 border-t border-border-subtle">
<div className="flex items-center gap-2">
<Button type="button" variant="ghost" className="text-ruby-600 px-2" onClick={handleReset} disabled={submitting}> <Button type="button" variant="ghost" className="text-ruby-600 px-2" onClick={handleReset} disabled={submitting}>
Reset Draft Reset Draft
</Button> </Button>
{isLocalhost && (
<Button type="button" variant="secondary" className="px-2 text-indigo-600 border-indigo-200 bg-indigo-50 hover:bg-indigo-100" onClick={handleSeedData} disabled={submitting}>
Seed Data
</Button>
)}
</div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{onCancel && ( {onCancel && (
<Button type="button" variant="secondary" onClick={onCancel} disabled={submitting}> <Button type="button" variant="secondary" onClick={onCancel} disabled={submitting}>

View File

@ -54,7 +54,7 @@ export function FileInput({
return ( return (
<div className="flex flex-col gap-1.5 font-sans"> <div className="flex flex-col gap-1.5 font-sans">
<span className="text-sm font-medium text-muted"> <span className="text-sm font-semibold text-strong mb-1">
{label} {label}
{required && <span className="text-ruby-600"> *</span>} {required && <span className="text-ruby-600"> *</span>}
</span> </span>
@ -65,7 +65,7 @@ export function FileInput({
required={required && files.length === 0} required={required && files.length === 0}
accept={type === 'image' ? 'image/*' : undefined} accept={type === 'image' ? 'image/*' : undefined}
onChange={handleFileChange} onChange={handleFileChange}
className="w-full text-sm text-strong file:mr-4 file:py-2 file:px-4 file:rounded file:border-0 file:text-sm file:font-semibold file:bg-slate-100 file:text-navy-700 hover:file:bg-slate-200" className="w-full text-sm text-strong file:mr-4 file:py-2 file:px-4 file:rounded file:border-0 file:text-sm file:font-semibold file:bg-slate-100 file:text-navy-700 hover:file:bg-slate-200 border border-border-default rounded-md bg-card p-1.5 focus:outline-none"
/> />
{files.length > 0 && ( {files.length > 0 && (

View File

@ -60,7 +60,16 @@ export function GeolocationInput({
setLoading(false); setLoading(false);
}, },
(err) => { (err) => {
setError(err.message || "Unable to get location"); let msg = err.message || "Unable to get location";
if (err.code === 1) { // PERMISSION_DENIED
msg = "Location permission denied. Please enable location access in your browser/device settings to use this feature.";
} else if (err.code === 2) { // POSITION_UNAVAILABLE
msg = "Location is turned off or unavailable. Please enable GPS/Location on your device and try again.";
} else if (err.code === 3) { // TIMEOUT
msg = "Location request timed out. Please ensure GPS is enabled and try again.";
}
setError(msg);
window.alert(msg);
setLoading(false); setLoading(false);
}, },
{ enableHighAccuracy: true, timeout: 10000 } { enableHighAccuracy: true, timeout: 10000 }
@ -115,7 +124,7 @@ export function GeolocationInput({
{required && <span className="text-red-500">*</span>} {required && <span className="text-red-500">*</span>}
</label> </label>
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap rounded-lg border border-transparent p-0.5 -ml-0.5 transition-colors">
<button <button
type="button" type="button"
onClick={fetchLocation} onClick={fetchLocation}
@ -133,6 +142,7 @@ export function GeolocationInput({
<MapIcon className="h-3.5 w-3.5" /> <MapIcon className="h-3.5 w-3.5" />
Select from Map Select from Map
</button> </button>
{required && !hasValidCoords && <input type="text" className="sr-only" required tabIndex={-1} onFocus={fetchLocation} />}
</div> </div>
{hasValidCoords ? ( {hasValidCoords ? (
@ -159,7 +169,6 @@ export function GeolocationInput({
)} )}
{error && <div className="text-xs text-red-500">{error}</div>} {error && <div className="text-xs text-red-500">{error}</div>}
{required && !hasValidCoords && <input type="text" className="sr-only" required tabIndex={-1} />}
{isMapModalOpen && ( {isMapModalOpen && (
<Modal <Modal

View File

@ -24,6 +24,8 @@ export function RadioField({ label, required, value, options, onChange }: RadioF
<label key={String(opt.value)} className="flex items-center gap-2 cursor-pointer"> <label key={String(opt.value)} className="flex items-center gap-2 cursor-pointer">
<input <input
type="radio" type="radio"
name={label}
required={required}
value={String(opt.value)} value={String(opt.value)}
checked={String(value) === String(opt.value)} checked={String(value) === String(opt.value)}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}

View File

@ -12,6 +12,7 @@ export function SmartGridField({
onChange, onChange,
isActivityPotentialMining = false, isActivityPotentialMining = false,
required = false, required = false,
error = false,
}: { }: {
label: string; label: string;
columns: FormScreenField[]; columns: FormScreenField[];
@ -19,6 +20,7 @@ export function SmartGridField({
onChange: (val: Record<string, unknown>[]) => void; onChange: (val: Record<string, unknown>[]) => void;
isActivityPotentialMining?: boolean; isActivityPotentialMining?: boolean;
required?: boolean; required?: boolean;
error?: boolean;
}) { }) {
const isPotentialMining = isActivityPotentialMining; const isPotentialMining = isActivityPotentialMining;
const formRef = useRef<HTMLDivElement>(null); const formRef = useRef<HTMLDivElement>(null);
@ -99,16 +101,21 @@ export function SmartGridField({
row['row_kgs'] = skuVal * bagsVal; row['row_kgs'] = skuVal * bagsVal;
} }
const isBagsCol = colDef && (colDef.id === 'bags' || colDef.name === 'Bags' || colDef.mapped_workflow_field === 'bags' || getBaseId(colDef.id) === 'bags' || (colDef.id || '').toLowerCase().includes('quantity') || (colDef.name || '').toLowerCase().includes('quantity') || (colDef.mapped_workflow_field || '').toLowerCase().includes('quantity'));
if (isBagsCol && val && String(val).trim() !== '') {
const prodNameCol = columns.find(c => c.id === 'product_name' || c.name === 'Product Name' || c.mapped_workflow_field === 'product_name' || getBaseId(c.id) === 'product_name' || (c.id || '').toLowerCase().includes('product'));
if (prodNameCol && (!row[prodNameCol.id] || String(row[prodNameCol.id]).trim() === '')) {
setErrors(prev => ({ ...prev, [prodNameCol.id]: 'Please select a product first' }));
}
}
setNewRow(row); setNewRow(row);
}; };
const submitNewRow = () => { const submitNewRow = () => {
const newErrors: Record<string, string> = {}; const newErrors: Record<string, string> = {};
const getBaseId = (id: string) => id.replace(/_\d+$/, ''); const getBaseId = (id: string) => id.replace(/_\d+$/, '');
const catColId = columns.find(c => c.id === 'product_category' || c.name === 'Product Category' || c.mapped_workflow_field === 'product_category' || getBaseId(c.id) === 'product_category')?.id;
const isCatSelected = catColId ? !!newRow[catColId] : false;
const isOrderDetails = label.toLowerCase().includes('order');
const hasAnyValue = visibleColumns.some(col => newRow[col.id] !== undefined && newRow[col.id] !== null && String(newRow[col.id]).trim() !== ''); const hasAnyValue = visibleColumns.some(col => newRow[col.id] !== undefined && newRow[col.id] !== null && String(newRow[col.id]).trim() !== '');
for (const col of visibleColumns) { for (const col of visibleColumns) {
@ -121,12 +128,13 @@ export function SmartGridField({
const isProductName = col.id === 'product_name' || col.name === 'Product Name' || col.mapped_workflow_field === 'product_name' || getBaseId(col.id) === 'product_name'; const isProductName = col.id === 'product_name' || col.name === 'Product Name' || col.mapped_workflow_field === 'product_name' || getBaseId(col.id) === 'product_name';
const isBags = col.id === 'bags' || col.name === 'Bags' || col.mapped_workflow_field === 'bags' || getBaseId(col.id) === 'bags' || (col.id || '').toLowerCase().includes('quantity') || (col.name || '').toLowerCase().includes('quantity') || (col.mapped_workflow_field || '').toLowerCase().includes('quantity'); const isBags = col.id === 'bags' || col.name === 'Bags' || col.mapped_workflow_field === 'bags' || getBaseId(col.id) === 'bags' || (col.id || '').toLowerCase().includes('quantity') || (col.name || '').toLowerCase().includes('quantity') || (col.mapped_workflow_field || '').toLowerCase().includes('quantity');
let isRequired = col.mandatory || (isCatSelected && (isProductName || isBags)); let isRequired = col.mandatory;
if (isOrderDetails && (isCategory || isProductName || isBags)) {
if (hasAnyValue) {
if (isCategory || isProductName || isBags) {
isRequired = true; isRequired = true;
} }
} else {
if (!hasAnyValue) {
isRequired = true; // if completely empty, require fields to prevent empty row addition isRequired = true; // if completely empty, require fields to prevent empty row addition
} }
@ -140,44 +148,62 @@ export function SmartGridField({
return; return;
} }
const prodNameCol = columns.find(c => c.id === 'product_name' || getBaseId(c.id) === 'product_name' || c.mapped_workflow_field === 'product_name' || (c.name || '').toLowerCase().includes('product name'));
const catCol = columns.find(c => c.id === 'product_category' || getBaseId(c.id) === 'product_category' || c.mapped_workflow_field === 'product_category' || (c.name || '').toLowerCase().includes('category'));
let foundIdx = -1;
if (prodNameCol && newRow[prodNameCol.id]) {
foundIdx = value.findIndex(r => r[prodNameCol.id] === newRow[prodNameCol.id]);
} else if (catCol && newRow[catCol.id]) {
foundIdx = value.findIndex(r => r[catCol.id] === newRow[catCol.id]);
}
// If duplicate found and it's NOT the row we are currently editing
if (foundIdx !== -1 && foundIdx !== editingIdx) {
if (label.toLowerCase().includes('potential') && editingIdx === null) {
const bagsCol = columns.find(c => c.id === 'bags' || c.name === 'Bags' || c.mapped_workflow_field === 'bags' || getBaseId(c.id) === 'bags' || (c.id || '').toLowerCase().includes('quantity') || (c.name || '').toLowerCase().includes('quantity') || (c.mapped_workflow_field || '').toLowerCase().includes('quantity'));
if (bagsCol) {
const next = [...value];
const existingRow = { ...next[foundIdx] };
const existingBags = Number(existingRow[bagsCol.id]) || 0;
const newBags = Number(newRow[bagsCol.id]) || 0;
existingRow[bagsCol.id] = existingBags + newBags;
const skuCol = columns.find(c => c.id === 'sku' || c.mapped_workflow_field === 'sku' || getBaseId(c.id) === 'sku');
const rowKgsCol = columns.find(c => c.id === 'row_kgs' || c.mapped_workflow_field === 'row_kgs' || getBaseId(c.id) === 'row_kgs' || getBaseId(c.id) === 'rowkgs');
const skuVal = skuCol ? Number(existingRow[skuCol.id]) : Number(existingRow['sku'] || 0);
if (rowKgsCol) {
existingRow[rowKgsCol.id] = skuVal * (existingBags + newBags);
} else {
existingRow['row_kgs'] = skuVal * (existingBags + newBags);
}
next[foundIdx] = existingRow;
onChange(next);
setNewRow({});
setEditingIdx(null);
return;
}
}
const fieldId = prodNameCol ? prodNameCol.id : catCol?.id;
if (fieldId) {
setErrors({ [fieldId]: 'This product is already added' });
return;
}
}
if (editingIdx !== null) { if (editingIdx !== null) {
const next = [...value]; const next = [...value];
next[editingIdx] = newRow; next[editingIdx] = newRow;
onChange(next); onChange(next);
} else {
const getBaseId = (id: string) => id.replace(/_\d+$/, '');
const prodNameCol = columns.find(c => c.id === 'product_name' || getBaseId(c.id) === 'product_name' || c.mapped_workflow_field === 'product_name');
let foundIdx = -1;
if (prodNameCol && newRow[prodNameCol.id]) {
foundIdx = value.findIndex(r => r[prodNameCol.id] === newRow[prodNameCol.id]);
}
if (foundIdx !== -1) {
const next = [...value];
const existingRow = { ...next[foundIdx] };
const bagsCol = columns.find(c => c.id === 'bags' || getBaseId(c.id) === 'bags' || c.mapped_workflow_field === 'bags' || (c.name || '').toLowerCase() === 'bags');
const rowKgsCol = columns.find(c => c.id === 'row_kgs' || getBaseId(c.id) === 'row_kgs' || c.mapped_workflow_field === 'row_kgs' || getBaseId(c.id) === 'rowkgs');
const skuCol = columns.find(c => c.id === 'sku' || getBaseId(c.id) === 'sku' || c.mapped_workflow_field === 'sku');
const bagsId = bagsCol ? bagsCol.id : 'bags';
const rowKgsId = rowKgsCol ? rowKgsCol.id : 'row_kgs';
const skuId = skuCol ? skuCol.id : 'sku';
const existingBags = Number(existingRow[bagsId]) || 0;
const addedBags = Number(newRow[bagsId]) || 0;
existingRow[bagsId] = existingBags + addedBags;
const skuVal = Number(existingRow[skuId]) || 0;
existingRow[rowKgsId] = (existingBags + addedBags) * skuVal;
next[foundIdx] = existingRow;
onChange(next);
} else { } else {
onChange([...value, newRow]); onChange([...value, newRow]);
} }
}
// Only close form if we were editing an existing row. For new rows, stay open. // Only close form if we were editing an existing row. For new rows, stay open.
if (editingIdx !== null) { if (editingIdx !== null) {
@ -241,6 +267,7 @@ export function SmartGridField({
label={col.name} label={col.name}
required={isRequired} required={isRequired}
error={errors[col.id]} error={errors[col.id]}
errorAsPlaceholder={true}
value={(val as string) ?? ''} value={(val as string) ?? ''}
onChange={(e) => updateNewRowField(col.id, e.target.value)} onChange={(e) => updateNewRowField(col.id, e.target.value)}
options={[{ value: '', label: 'Select...' }, ...filteredOpts.map((o: any) => ({ value: String(o.value), label: o.label }))]} options={[{ value: '', label: 'Select...' }, ...filteredOpts.map((o: any) => ({ value: String(o.value), label: o.label }))]}
@ -253,6 +280,7 @@ export function SmartGridField({
label={col.name} label={col.name}
required={isRequired} required={isRequired}
error={errors[col.id]} error={errors[col.id]}
errorAsPlaceholder={true}
type={col.data_type === 'number' ? 'number' : col.data_type === 'email' ? 'email' : 'text'} type={col.data_type === 'number' ? 'number' : col.data_type === 'email' ? 'email' : 'text'}
value={(val as string) ?? ''} value={(val as string) ?? ''}
onChange={(e) => updateNewRowField(col.id, col.data_type === 'number' ? Number(e.target.value) : e.target.value)} onChange={(e) => updateNewRowField(col.id, col.data_type === 'number' ? Number(e.target.value) : e.target.value)}
@ -262,7 +290,7 @@ export function SmartGridField({
}; };
return ( return (
<div className="flex flex-col gap-2 font-sans border border-slate-200 rounded-2xl p-4 bg-card shadow-sm mt-2"> <div className="flex flex-col gap-2 font-sans border border-slate-200 rounded-2xl p-4 bg-card shadow-sm mt-2 transition-colors">
<span className="text-sm font-semibold text-strong mb-2"> <span className="text-sm font-semibold text-strong mb-2">
{label} {label}
{required && <span className="text-ruby-600"> *</span>} {required && <span className="text-ruby-600"> *</span>}

View File

@ -1,3 +1,4 @@
import { useState } from 'react';
import { Input } from '../../reusable/Input'; import { Input } from '../../reusable/Input';
export function TextField({ export function TextField({
@ -17,15 +18,30 @@ export function TextField({
disabled?: boolean; disabled?: boolean;
readOnly?: boolean; readOnly?: boolean;
}) { }) {
const [touched, setTouched] = useState(false);
const isPincode = label.toLowerCase().replace(/\s/g, '') === 'pincode';
return ( return (
<Input <Input
label={label} label={label}
required={required} required={required}
type={type === 'number' ? 'number' : type === 'email' ? 'email' : 'text'} type={isPincode ? 'text' : type === 'number' ? 'number' : type === 'email' ? 'email' : 'text'}
inputMode={isPincode || type === 'number' ? 'numeric' : undefined}
value={value ?? ''} value={value ?? ''}
onChange={(e) => onChange(e.target.value)} onChange={(e) => {
let val = e.target.value;
if (isPincode) {
val = val.replace(/\D/g, '').slice(0, 6);
}
onChange(val);
}}
onBlur={() => setTouched(true)}
disabled={disabled} disabled={disabled}
readOnly={readOnly} readOnly={readOnly}
pattern={isPincode ? '^\\d{6}$' : undefined}
title={isPincode ? 'PIN Code must be exactly 6 digits' : undefined}
/> />
); );
} }

View File

@ -11,6 +11,8 @@ export interface InputProps extends Omit<InputHTMLAttributes<HTMLInputElement>,
suffix?: ReactNode; suffix?: ReactNode;
/** Class for the outer label wrapper. */ /** Class for the outer label wrapper. */
className?: string; className?: string;
/** Whether to render the error message as a placeholder instead of below the input */
errorAsPlaceholder?: boolean;
} }
/** Labeled text/number input with optional prefix, suffix, hint and error. */ /** Labeled text/number input with optional prefix, suffix, hint and error. */
@ -23,6 +25,7 @@ export function Input({
required, required,
className, className,
disabled, disabled,
errorAsPlaceholder,
...rest ...rest
}: InputProps) { }: InputProps) {
return ( return (
@ -44,16 +47,18 @@ export function Input({
<input <input
required={required} required={required}
disabled={disabled} disabled={disabled}
placeholder={errorAsPlaceholder && error && !rest.value && !rest.defaultValue ? error : rest.placeholder}
className={cn( className={cn(
'flex-1 w-full border-none outline-none bg-transparent font-sans text-base', 'flex-1 w-full border-none outline-none bg-transparent font-sans text-base',
disabled ? 'text-muted cursor-not-allowed' : 'text-strong', errorAsPlaceholder && error && !rest.value && !rest.defaultValue ? 'placeholder:text-ruby-600 placeholder:font-medium placeholder:text-[13px]' : 'placeholder:text-muted',
disabled ? 'text-muted cursor-not-allowed' : (error ? 'text-ruby-600' : 'text-strong'),
)} )}
{...rest} {...rest}
/> />
{suffix && <span className="text-faint text-sm">{suffix}</span>} {suffix && <span className="text-faint text-sm">{suffix}</span>}
</div> </div>
{(hint || error) && ( {(hint || (!errorAsPlaceholder && error)) && (
<span className={cn('text-xs', error ? 'text-ruby-600' : 'text-faint')}>{error || hint}</span> <span className={cn('text-xs', error ? 'text-ruby-600' : 'text-faint')}>{!errorAsPlaceholder && error ? error : hint}</span>
)} )}
</label> </label>
); );

View File

@ -22,6 +22,8 @@ export interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
onDropdownOpen?: () => void; onDropdownOpen?: () => void;
/** Callback fired when search query changes */ /** Callback fired when search query changes */
onSearch?: (query: string) => void; onSearch?: (query: string) => void;
/** Whether to render the error message as a placeholder instead of below the input */
errorAsPlaceholder?: boolean;
} }
/** Custom searchable select component using React Portal to prevent container clipping. */ /** Custom searchable select component using React Portal to prevent container clipping. */
@ -37,6 +39,7 @@ export function Select({
searchable = true, searchable = true,
onDropdownOpen, onDropdownOpen,
onSearch, onSearch,
errorAsPlaceholder,
...rest ...rest
}: SelectProps) { }: SelectProps) {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
@ -113,9 +116,25 @@ export function Select({
isOpen ? "border-navy-600 ring-2 ring-navy-600/20" : error ? "border-ruby-600" : "border-border-default" isOpen ? "border-navy-600 ring-2 ring-navy-600/20" : error ? "border-ruby-600" : "border-border-default"
)} )}
> >
<span className={cn("text-base truncate pr-2", selectedOptionLabel ? "text-strong" : "text-faint")}> <span className={cn("text-base truncate pr-2 w-full", (errorAsPlaceholder && error && !selectedOptionLabel) ? "text-ruby-600 font-medium text-[13px]" : (selectedOptionLabel ? "text-strong" : "text-faint"))}>
{selectedOptionLabel || placeholder || "Select..."} {selectedOptionLabel || ((errorAsPlaceholder && error) ? error : (placeholder || "Select..."))}
</span> </span>
<select
required={required}
value={currentValue}
disabled={disabled}
className="sr-only"
aria-hidden="true"
tabIndex={-1}
onChange={() => {}}
>
<option value="" disabled></option>
{normalizedOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
<ChevronDown size={15} className={cn("transition-transform duration-150 text-faint shrink-0 ml-1", isOpen && "rotate-180")} /> <ChevronDown size={15} className={cn("transition-transform duration-150 text-faint shrink-0 ml-1", isOpen && "rotate-180")} />
</div> </div>
@ -181,25 +200,8 @@ export function Select({
</div> </div>
)} )}
{/* Hidden Native Select for Required/Form Validation */} {(hint || (!errorAsPlaceholder && error)) && (
<select <span className={cn('text-xs', error ? 'text-ruby-600' : 'text-faint')}>{!errorAsPlaceholder && error ? error : hint}</span>
required={required}
value={currentValue}
disabled={disabled}
className="sr-only"
aria-hidden="true"
tabIndex={-1}
onChange={() => {}}
>
{normalizedOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
{(hint || error) && (
<span className={cn('text-xs', error ? 'text-ruby-600' : 'text-faint')}>{error || hint}</span>
)} )}
</div> </div>
); );
@ -221,6 +223,8 @@ export interface MultiSelectProps {
searchable?: boolean; searchable?: boolean;
/** Callback fired when search query changes */ /** Callback fired when search query changes */
onSearch?: (query: string) => void; onSearch?: (query: string) => void;
/** Whether to render the error message as a placeholder instead of below the input */
errorAsPlaceholder?: boolean;
} }
/** Custom searchable multiselect component using React Portal. */ /** Custom searchable multiselect component using React Portal. */
@ -237,6 +241,7 @@ export function MultiSelect({
placeholder, placeholder,
searchable = true, searchable = true,
onSearch, onSearch,
errorAsPlaceholder,
}: MultiSelectProps) { }: MultiSelectProps) {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
@ -327,9 +332,25 @@ export function MultiSelect({
isOpen ? "border-navy-600 ring-2 ring-navy-600/20" : error ? "border-ruby-600" : "border-border-default" isOpen ? "border-navy-600 ring-2 ring-navy-600/20" : error ? "border-ruby-600" : "border-border-default"
)} )}
> >
<span className={cn("text-xs truncate pr-2", currentValues.length > 0 ? "text-strong font-semibold" : "text-faint")}> <span className={cn("text-xs truncate pr-2 w-full", (errorAsPlaceholder && error && currentValues.length === 0) ? "text-ruby-600 font-medium text-[13px]" : (currentValues.length > 0 ? "text-strong font-semibold" : "text-faint"))}>
{triggerText} {currentValues.length === 0 && errorAsPlaceholder && error ? error : triggerText}
</span> </span>
<select
multiple
required={required}
value={currentValues}
disabled={disabled}
className="sr-only"
aria-hidden="true"
tabIndex={-1}
onChange={() => {}}
>
{normalizedOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
<ChevronDown size={15} className={cn("transition-transform duration-150 text-faint shrink-0 ml-1", isOpen && "rotate-180")} /> <ChevronDown size={15} className={cn("transition-transform duration-150 text-faint shrink-0 ml-1", isOpen && "rotate-180")} />
</div> </div>
@ -419,8 +440,8 @@ export function MultiSelect({
</div> </div>
)} )}
{(hint || error) && ( {(hint || (!errorAsPlaceholder && error)) && (
<span className={cn('text-xs', error ? 'text-ruby-600' : 'text-faint')}>{error || hint}</span> <span className={cn('text-xs', error ? 'text-ruby-600' : 'text-faint')}>{!errorAsPlaceholder && error ? error : hint}</span>
)} )}
</div> </div>
); );

View File

@ -530,3 +530,11 @@
-ms-overflow-style: none; -ms-overflow-style: none;
scrollbar-width: none; scrollbar-width: none;
} }
/* Show invalid inputs when form is submitted with errors */
.show-invalid :invalid,
.show-invalid :has(> input:invalid),
.show-invalid :has(> select:invalid),
.show-invalid :has(> textarea:invalid) {
border-color: var(--color-ruby-600) !important;
}