diff --git a/README.md b/README.md new file mode 100644 index 0000000..58289b1 --- /dev/null +++ b/README.md @@ -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) + diff --git a/package.json b/package.json index 349ac82..8e1dcfe 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "krishna-sales-pwa", "private": true, - "version": "0.0.0", + "version": "1.0.2", "type": "module", "scripts": { "dev": "vite", diff --git a/src/api/client.ts b/src/api/client.ts index a65357e..f954589 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -82,13 +82,17 @@ export class ZinoClient { } if (!res.ok) { let message = res.statusText; + let field_errors: any[] | undefined; + let code: string | undefined; try { - const j = (await res.json()) as { error?: string }; + const j = (await res.json()) as any; if (j.error) message = j.error; + if (j.field_errors) field_errors = j.field_errors; + if (j.code) code = j.code; } catch { /* 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; return (await res.json()) as T; diff --git a/src/api/config.ts b/src/api/config.ts index f715c25..c480777 100644 --- a/src/api/config.ts +++ b/src/api/config.ts @@ -271,5 +271,5 @@ export const HIDDEN_FORM_FIELDS = [ export const SELECT_OPTION_LIMIT = 200; 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; diff --git a/src/components/forms/DynamicForm.tsx b/src/components/forms/DynamicForm.tsx index aa77edd..b610102 100644 --- a/src/components/forms/DynamicForm.tsx +++ b/src/components/forms/DynamicForm.tsx @@ -203,6 +203,7 @@ export function DynamicForm({ client, activityId: initialActivityId, instanceId: const [values, setValues] = useState>({}); const [submitting, setSubmitting] = useState(false); const [submitError, setSubmitError] = useState(null); + const [backendErrors, setBackendErrors] = useState>({}); const clickedActionRef = useRef(null); 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 = { ...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) => { + setSubmitError(null); + setBackendErrors(prev => { + if (!prev[fieldId]) return prev; + const next = { ...prev }; + delete next[fieldId]; + return next; + }); setValues(prev => { 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 normalFields = fields.filter(f => f !== actionField); - const handleSubmit = async (e: React.FormEvent) => { + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); + + const formElement = e.currentTarget; + if (!formElement.checkValidity()) { + const invalidElements = formElement.querySelectorAll(':invalid'); + const newErrors: Record = {}; + + 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); setSubmitError(null); + setBackendErrors({}); try { const payload: Record = {}; const finalValues = { ...values }; @@ -386,7 +477,17 @@ export function DynamicForm({ client, activityId: initialActivityId, instanceId: if (f.mandatory && f.data_type === 'grid') { const val = finalValues[f.id]; 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); return; } @@ -418,24 +519,23 @@ export function DynamicForm({ client, activityId: initialActivityId, instanceId: phone: 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 = []; - for (const file of val) { - let fileToUpload = file as File; - if (!(fileToUpload instanceof File)) { - fileToUpload = new File([file], (file as any).name || "image.jpg", { type: file.type || 'image/jpeg' }); + for (const fileItem of fileList) { + if (fileItem instanceof File || fileItem instanceof Blob) { + let fileToUpload = fileItem as File; + if (!(fileToUpload instanceof File)) { + 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 }); + uploadedFiles.push(fileMeta); + } else { + // Already uploaded (e.g., { uuid: '...' }) or just a string URL + uploadedFiles.push(fileItem); } - const fileMeta = await client.uploadFile(fileToUpload, { activityId: currentActivityId, fieldId: f.id, instanceId: currentInstanceId }); - uploadedFiles.push(fileMeta); } 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 { if (f.data_type === 'grid' && Array.isArray(val)) { val = val.map(row => { @@ -574,13 +674,37 @@ export function DynamicForm({ client, activityId: initialActivityId, instanceId: } } catch (err: any) { setSubmitError(err.message || 'Failed to submit form'); + if (err.field_errors && Array.isArray(err.field_errors)) { + const newErrors: Record = {}; + 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 { setSubmitting(false); } }; return ( -
+ {normalFields.map(f => { const type = f.data_type; const val = values[f.id]; @@ -657,6 +781,7 @@ export function DynamicForm({ client, activityId: initialActivityId, instanceId: onChange={(newVal) => handleFieldChange(f.id, newVal)} isActivityPotentialMining={currentActivityId === ORDER_BOOKING.activities.POTENTIAL_MINING.uid} required={f.mandatory} + error={!!backendErrors[f.id]} /> ); } @@ -756,22 +881,33 @@ export function DynamicForm({ client, activityId: initialActivityId, instanceId: const content = renderField(); - return isDisabled ? ( -
+
{content}
) : ( -
{content}
+
+ {content} + {backendErrors[f.id] && ( +
{backendErrors[f.id]}
+ )} +
); })} {submitError &&
{submitError}
}
- +
+ + {isLocalhost && ( + + )} +
{onCancel && (
- ); - } + ); + } + + if (type.startsWith('time')) { + return ( + handleFieldChange(fieldId, v)} + /> + ); + } + + if (type === 'image' || type === 'file') { + return ( + handleFieldChange(fieldId, v)} + /> + ); + } - if (type.startsWith('date')) { return ( - { - setValues(p => { - const next = { ...p, [fieldId]: v, date_of_visit: v }; - valuesRef.current = next; - return next; - }); - }} - /> - ); - } - - if (type.startsWith('time')) { - return ( - { - setValues(p => { - const next = { ...p, [fieldId]: v, time_of_visit: v }; - valuesRef.current = next; - return next; - }); - }} - /> - ); - } - - if (type === 'image' || type === 'file') { - return ( - { - setValues(p => { - const next = { ...p, [fieldId]: v, upload_image: v }; - valuesRef.current = next; - return next; - }); - }} + value={(val as string) || ''} + onChange={v => handleFieldChange(fieldId, v)} /> ); - } + }; + const content = renderField(); + return ( - { - setValues(p => { - const next = { ...p, [fieldId]: v }; - valuesRef.current = next; - return next; - }); - }} - /> +
+ {content} + {backendErrors[fieldId] && ( +
{backendErrors[fieldId]}
+ )} +
); })} {submitError &&
{submitError}
}
- +
+ + {isLocalhost && ( + + )} +
{onCancel && ( + {required && !hasValidCoords && }
{hasValidCoords ? ( @@ -159,8 +169,7 @@ export function GeolocationInput({ )} {error &&
{error}
} - {required && !hasValidCoords && } - + {isMapModalOpen && ( onChange(e.target.value)} diff --git a/src/components/forms/fields/SmartGridField.tsx b/src/components/forms/fields/SmartGridField.tsx index ef67250..3b02d19 100644 --- a/src/components/forms/fields/SmartGridField.tsx +++ b/src/components/forms/fields/SmartGridField.tsx @@ -12,6 +12,7 @@ export function SmartGridField({ onChange, isActivityPotentialMining = false, required = false, + error = false, }: { label: string; columns: FormScreenField[]; @@ -19,6 +20,7 @@ export function SmartGridField({ onChange: (val: Record[]) => void; isActivityPotentialMining?: boolean; required?: boolean; + error?: boolean; }) { const isPotentialMining = isActivityPotentialMining; const formRef = useRef(null); @@ -99,16 +101,21 @@ export function SmartGridField({ 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); }; const submitNewRow = () => { const newErrors: Record = {}; 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() !== ''); for (const col of visibleColumns) { @@ -121,13 +128,14 @@ 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 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)); - if (isOrderDetails && (isCategory || isProductName || isBags)) { - isRequired = true; - } + let isRequired = col.mandatory; - if (!hasAnyValue) { - isRequired = true; // if completely empty, require fields to prevent empty row addition + if (hasAnyValue) { + if (isCategory || isProductName || isBags) { + isRequired = true; + } + } else { + isRequired = true; // if completely empty, require fields to prevent empty row addition } if (isRequired && (!newRow[col.id] || String(newRow[col.id]).trim() === '')) { @@ -140,43 +148,61 @@ export function SmartGridField({ 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) { const next = [...value]; next[editingIdx] = newRow; 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 { - onChange([...value, newRow]); - } + onChange([...value, newRow]); } // Only close form if we were editing an existing row. For new rows, stay open. @@ -241,6 +267,7 @@ export function SmartGridField({ label={col.name} required={isRequired} error={errors[col.id]} + errorAsPlaceholder={true} value={(val as string) ?? ''} onChange={(e) => updateNewRowField(col.id, e.target.value)} 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} required={isRequired} error={errors[col.id]} + errorAsPlaceholder={true} type={col.data_type === 'number' ? 'number' : col.data_type === 'email' ? 'email' : 'text'} value={(val as string) ?? ''} onChange={(e) => updateNewRowField(col.id, col.data_type === 'number' ? Number(e.target.value) : e.target.value)} @@ -262,7 +290,7 @@ export function SmartGridField({ }; return ( -
+
{label} {required && *} diff --git a/src/components/forms/fields/TextField.tsx b/src/components/forms/fields/TextField.tsx index 65a7f51..1c21f4e 100644 --- a/src/components/forms/fields/TextField.tsx +++ b/src/components/forms/fields/TextField.tsx @@ -1,3 +1,4 @@ +import { useState } from 'react'; import { Input } from '../../reusable/Input'; export function TextField({ @@ -17,15 +18,30 @@ export function TextField({ disabled?: boolean; readOnly?: boolean; }) { + const [touched, setTouched] = useState(false); + + const isPincode = label.toLowerCase().replace(/\s/g, '') === 'pincode'; + return ( 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} readOnly={readOnly} + pattern={isPincode ? '^\\d{6}$' : undefined} + title={isPincode ? 'PIN Code must be exactly 6 digits' : undefined} /> ); } + diff --git a/src/components/reusable/Input.tsx b/src/components/reusable/Input.tsx index 124bf26..40c25f9 100644 --- a/src/components/reusable/Input.tsx +++ b/src/components/reusable/Input.tsx @@ -11,6 +11,8 @@ export interface InputProps extends Omit, suffix?: ReactNode; /** Class for the outer label wrapper. */ 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. */ @@ -23,6 +25,7 @@ export function Input({ required, className, disabled, + errorAsPlaceholder, ...rest }: InputProps) { return ( @@ -44,16 +47,18 @@ export function Input({ {suffix && {suffix}}
- {(hint || error) && ( - {error || hint} + {(hint || (!errorAsPlaceholder && error)) && ( + {!errorAsPlaceholder && error ? error : hint} )} ); diff --git a/src/components/reusable/Select.tsx b/src/components/reusable/Select.tsx index 0fb3f85..9be6bf1 100644 --- a/src/components/reusable/Select.tsx +++ b/src/components/reusable/Select.tsx @@ -22,6 +22,8 @@ export interface SelectProps extends SelectHTMLAttributes { onDropdownOpen?: () => void; /** Callback fired when search query changes */ 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. */ @@ -37,6 +39,7 @@ export function Select({ searchable = true, onDropdownOpen, onSearch, + errorAsPlaceholder, ...rest }: SelectProps) { 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" )} > - - {selectedOptionLabel || placeholder || "Select..."} + + {selectedOptionLabel || ((errorAsPlaceholder && error) ? error : (placeholder || "Select..."))} +
@@ -181,25 +200,8 @@ export function Select({
)} - {/* Hidden Native Select for Required/Form Validation */} - - - {(hint || error) && ( - {error || hint} + {(hint || (!errorAsPlaceholder && error)) && ( + {!errorAsPlaceholder && error ? error : hint} )}
); @@ -221,6 +223,8 @@ export interface MultiSelectProps { searchable?: boolean; /** Callback fired when search query changes */ 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. */ @@ -237,6 +241,7 @@ export function MultiSelect({ placeholder, searchable = true, onSearch, + errorAsPlaceholder, }: MultiSelectProps) { const [isOpen, setIsOpen] = useState(false); 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" )} > - 0 ? "text-strong font-semibold" : "text-faint")}> - {triggerText} + 0 ? "text-strong font-semibold" : "text-faint"))}> + {currentValues.length === 0 && errorAsPlaceholder && error ? error : triggerText} + @@ -419,8 +440,8 @@ export function MultiSelect({ )} - {(hint || error) && ( - {error || hint} + {(hint || (!errorAsPlaceholder && error)) && ( + {!errorAsPlaceholder && error ? error : hint} )} ); diff --git a/src/styles/styles.css b/src/styles/styles.css index 174968e..b4a86d5 100644 --- a/src/styles/styles.css +++ b/src/styles/styles.css @@ -529,4 +529,12 @@ .hide-scrollbar { -ms-overflow-style: 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; } \ No newline at end of file