Compare commits
2 Commits
6ab7a51db4
...
8f419919bb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f419919bb | ||
|
|
904e1660f1 |
182
src/components/core/PickerShell.tsx
Normal file
182
src/components/core/PickerShell.tsx
Normal file
@ -0,0 +1,182 @@
|
|||||||
|
import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { ChevronDown, X } from 'lucide-react';
|
||||||
|
import { cn } from '../../lib/cn';
|
||||||
|
|
||||||
|
export interface PickerShellProps<T> {
|
||||||
|
label?: string;
|
||||||
|
required?: boolean;
|
||||||
|
hint?: string;
|
||||||
|
/** Class for the outer label wrapper (width etc.). */
|
||||||
|
className?: string;
|
||||||
|
/** Text shown in the trigger for the current selection ('' = nothing picked). */
|
||||||
|
displayLabel: string;
|
||||||
|
placeholder?: string;
|
||||||
|
/** Rows shown in the open panel (already filtered/fetched by the caller). */
|
||||||
|
items: T[];
|
||||||
|
renderItem: (item: T) => ReactNode;
|
||||||
|
itemKey: (item: T, i: number) => string | number;
|
||||||
|
onPick: (item: T) => void;
|
||||||
|
/** When provided and something is selected, a clear (✕) button shows. */
|
||||||
|
onClear?: () => void;
|
||||||
|
/** Show the search box (default true). */
|
||||||
|
searchable?: boolean;
|
||||||
|
search: string;
|
||||||
|
onSearch: (s: string) => void;
|
||||||
|
loading?: boolean;
|
||||||
|
emptyText?: string;
|
||||||
|
/** Notifies the caller so it can fetch on open / reset on close. */
|
||||||
|
onOpenChange?: (open: boolean) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shared searchable-picker chrome: trigger pill + portal dropdown with an
|
||||||
|
* optional search box and a scrollable option list. Both Select (static
|
||||||
|
* options) and LookupField (remote rows) render through this so the two are
|
||||||
|
* visually identical — one picker UI everywhere. The panel lives in a portal
|
||||||
|
* so a Card's `overflow-hidden` can't clip it. */
|
||||||
|
export function PickerShell<T>({
|
||||||
|
label,
|
||||||
|
required,
|
||||||
|
hint,
|
||||||
|
className,
|
||||||
|
displayLabel,
|
||||||
|
placeholder = 'Select…',
|
||||||
|
items,
|
||||||
|
renderItem,
|
||||||
|
itemKey,
|
||||||
|
onPick,
|
||||||
|
onClear,
|
||||||
|
searchable = true,
|
||||||
|
search,
|
||||||
|
onSearch,
|
||||||
|
loading,
|
||||||
|
emptyText = 'No matches.',
|
||||||
|
onOpenChange,
|
||||||
|
disabled,
|
||||||
|
}: PickerShellProps<T>) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const triggerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [pos, setPos] = useState<{ top: number; left: number; width: number } | null>(null);
|
||||||
|
|
||||||
|
const toggle = (next: boolean) => {
|
||||||
|
if (disabled) return;
|
||||||
|
setOpen(next);
|
||||||
|
onOpenChange?.(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Anchor the portal panel under the trigger; track scroll/resize while open.
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const place = () => {
|
||||||
|
const el = triggerRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const r = el.getBoundingClientRect();
|
||||||
|
setPos({ top: r.bottom + 4, left: r.left, width: r.width });
|
||||||
|
};
|
||||||
|
place();
|
||||||
|
window.addEventListener('scroll', place, true);
|
||||||
|
window.addEventListener('resize', place);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('scroll', place, true);
|
||||||
|
window.removeEventListener('resize', place);
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
// Close on outside click — ignore clicks inside the trigger or the portal panel.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const onDoc = (e: MouseEvent) => {
|
||||||
|
const t = e.target as Node;
|
||||||
|
if (triggerRef.current?.contains(t) || panelRef.current?.contains(t)) return;
|
||||||
|
toggle(false);
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', onDoc);
|
||||||
|
return () => document.removeEventListener('mousedown', onDoc);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<label className={cn('flex flex-col gap-1.5 font-sans', className)}>
|
||||||
|
{label && (
|
||||||
|
<span className="text-sm font-medium text-muted">
|
||||||
|
{label}
|
||||||
|
{required && <span className="text-ruby-600"> *</span>}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
ref={triggerRef}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2 bg-card rounded-md px-3 h-[42px] border border-border-default focus-ring',
|
||||||
|
disabled ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer',
|
||||||
|
)}
|
||||||
|
onClick={() => toggle(!open)}
|
||||||
|
>
|
||||||
|
<span className={cn('flex-1 truncate text-base', displayLabel ? 'text-strong' : 'text-faint')}>
|
||||||
|
{displayLabel || placeholder}
|
||||||
|
</span>
|
||||||
|
{onClear && displayLabel ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onClear();
|
||||||
|
}}
|
||||||
|
className="text-faint hover:text-strong"
|
||||||
|
>
|
||||||
|
<X size={15} />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<ChevronDown size={15} className="text-faint" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hint && <span className="text-xs text-faint">{hint}</span>}
|
||||||
|
|
||||||
|
{open &&
|
||||||
|
pos &&
|
||||||
|
createPortal(
|
||||||
|
<div
|
||||||
|
ref={panelRef}
|
||||||
|
style={{ position: 'fixed', top: pos.top, left: pos.left, width: pos.width, zIndex: 50 }}
|
||||||
|
className="bg-card border border-border-default rounded-md shadow-lg overflow-hidden"
|
||||||
|
>
|
||||||
|
{searchable && (
|
||||||
|
<div className="p-2 border-b border-border-subtle">
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => onSearch(e.target.value)}
|
||||||
|
placeholder="Search…"
|
||||||
|
className="w-full h-9 px-2.5 border border-border-default rounded-md outline-none text-sm focus:border-sunrise-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="max-h-56 overflow-auto">
|
||||||
|
{loading ? (
|
||||||
|
<div className="px-3 py-3 text-sm text-faint">Searching…</div>
|
||||||
|
) : items.length ? (
|
||||||
|
items.map((item, i) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={itemKey(item, i)}
|
||||||
|
onClick={() => {
|
||||||
|
onPick(item);
|
||||||
|
toggle(false);
|
||||||
|
}}
|
||||||
|
className="w-full text-left px-3 py-2.5 text-sm text-body hover:bg-slate-50 border-none bg-transparent cursor-pointer"
|
||||||
|
>
|
||||||
|
{renderItem(item)}
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="px-3 py-3 text-sm text-faint">{emptyText}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
91
src/components/core/SelectField.tsx
Normal file
91
src/components/core/SelectField.tsx
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { PickerShell } from './PickerShell';
|
||||||
|
|
||||||
|
export interface SelectFieldOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SelectFieldProps {
|
||||||
|
label?: string;
|
||||||
|
required?: boolean;
|
||||||
|
hint?: string;
|
||||||
|
className?: string;
|
||||||
|
/** Either strings or {value,label} objects. */
|
||||||
|
options?: Array<string | SelectFieldOption>;
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
/** Force the search box on/off. Defaults to on when there are >7 options. */
|
||||||
|
searchable?: boolean;
|
||||||
|
/** Allow clearing back to ''. Defaults to true for non-required fields. */
|
||||||
|
clearable?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const norm = (o: string | SelectFieldOption): SelectFieldOption =>
|
||||||
|
typeof o === 'string' ? { value: o, label: o } : o;
|
||||||
|
|
||||||
|
/** Single-select that renders through the shared PickerShell, so it matches
|
||||||
|
* LookupField exactly. Static options, searched/filtered locally. An empty-
|
||||||
|
* value option (e.g. {value:'', label:'Choose…'}) is treated as the
|
||||||
|
* placeholder rather than a list row, and option values are de-duplicated. */
|
||||||
|
export function SelectField({
|
||||||
|
label,
|
||||||
|
required,
|
||||||
|
hint,
|
||||||
|
className,
|
||||||
|
options = [],
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
searchable,
|
||||||
|
clearable,
|
||||||
|
disabled,
|
||||||
|
}: SelectFieldProps) {
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
|
||||||
|
// Normalize, lift any empty-value option's label to the placeholder, dedupe.
|
||||||
|
const { opts, ph } = useMemo(() => {
|
||||||
|
const all = options.map(norm);
|
||||||
|
const empty = all.find((o) => o.value === '');
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const opts = all.filter((o) => {
|
||||||
|
if (o.value === '' || seen.has(o.value)) return false;
|
||||||
|
seen.add(o.value);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
return { opts, ph: placeholder ?? empty?.label };
|
||||||
|
}, [options, placeholder]);
|
||||||
|
|
||||||
|
const items = useMemo(() => {
|
||||||
|
const q = search.trim().toLowerCase();
|
||||||
|
return q ? opts.filter((o) => o.label.toLowerCase().includes(q)) : opts;
|
||||||
|
}, [opts, search]);
|
||||||
|
|
||||||
|
const display = opts.find((o) => o.value === value)?.label ?? '';
|
||||||
|
const canSearch = searchable ?? opts.length > 7;
|
||||||
|
const canClear = (clearable ?? !required) && value !== '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PickerShell<SelectFieldOption>
|
||||||
|
label={label}
|
||||||
|
required={required}
|
||||||
|
hint={hint}
|
||||||
|
className={className}
|
||||||
|
displayLabel={display}
|
||||||
|
placeholder={ph}
|
||||||
|
items={items}
|
||||||
|
renderItem={(o) => o.label}
|
||||||
|
itemKey={(o) => o.value}
|
||||||
|
onPick={(o) => onChange(o.value)}
|
||||||
|
onClear={canClear ? () => onChange('') : undefined}
|
||||||
|
searchable={canSearch}
|
||||||
|
search={search}
|
||||||
|
onSearch={setSearch}
|
||||||
|
onOpenChange={(open) => !open && setSearch('')}
|
||||||
|
emptyText="No options."
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -10,5 +10,9 @@ export { Input } from './Input';
|
|||||||
export type { InputProps } from './Input';
|
export type { InputProps } from './Input';
|
||||||
export { Select } from './Select';
|
export { Select } from './Select';
|
||||||
export type { SelectProps, SelectOption } from './Select';
|
export type { SelectProps, SelectOption } from './Select';
|
||||||
|
export { SelectField } from './SelectField';
|
||||||
|
export type { SelectFieldProps, SelectFieldOption } from './SelectField';
|
||||||
|
export { PickerShell } from './PickerShell';
|
||||||
|
export type { PickerShellProps } from './PickerShell';
|
||||||
export { MultiSelect } from './MultiSelect';
|
export { MultiSelect } from './MultiSelect';
|
||||||
export type { MultiSelectProps, MultiSelectOption } from './MultiSelect';
|
export type { MultiSelectProps, MultiSelectOption } from './MultiSelect';
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
|
|||||||
import { CheckCircle2 } from 'lucide-react';
|
import { CheckCircle2 } from 'lucide-react';
|
||||||
import { Button } from '../core/Button';
|
import { Button } from '../core/Button';
|
||||||
import { Input } from '../core/Input';
|
import { Input } from '../core/Input';
|
||||||
import { Select } from '../core/Select';
|
import { SelectField } from '../core/SelectField';
|
||||||
import { MultiSelect } from '../core/MultiSelect';
|
import { MultiSelect } from '../core/MultiSelect';
|
||||||
import { LookupField } from './LookupField';
|
import { LookupField } from './LookupField';
|
||||||
import type { LookupValue } from './LookupField';
|
import type { LookupValue } from './LookupField';
|
||||||
@ -204,12 +204,13 @@ function Field({
|
|||||||
|
|
||||||
case 'select':
|
case 'select':
|
||||||
return (
|
return (
|
||||||
<Select
|
<SelectField
|
||||||
label={field.label}
|
label={field.label}
|
||||||
required={field.required}
|
required={field.required}
|
||||||
value={String(value ?? '')}
|
value={String(value ?? '')}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={onChange}
|
||||||
options={[{ value: '', label: field.required ? 'Select…' : '— None —' }, ...(field.options ?? [])]}
|
options={field.options ?? []}
|
||||||
|
placeholder={field.required ? 'Select…' : '— None —'}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,5 @@
|
|||||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { PickerShell } from '../core/PickerShell';
|
||||||
import { ChevronDown, X } from 'lucide-react';
|
|
||||||
import { cn } from '../../lib/cn';
|
|
||||||
import { useZino } from '../../api/provider';
|
import { useZino } from '../../api/provider';
|
||||||
|
|
||||||
export interface LookupValue {
|
export interface LookupValue {
|
||||||
@ -23,9 +21,11 @@ export interface LookupFieldProps {
|
|||||||
onChange: (v: LookupValue | null) => void;
|
onChange: (v: LookupValue | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Row = Record<string, unknown>;
|
||||||
|
|
||||||
/** Searchable RDBMS-lookup picker (owner agent etc.). Mirrors the renderer's
|
/** Searchable RDBMS-lookup picker (owner agent etc.). Mirrors the renderer's
|
||||||
* FFLookupField: projects the picked row onto storage + display columns. The
|
* FFLookupField: projects the picked row onto storage + display columns.
|
||||||
* dropdown renders in a portal so it isn't clipped by a Card's overflow. */
|
* Renders through the shared PickerShell so it's identical to SelectField. */
|
||||||
export function LookupField({
|
export function LookupField({
|
||||||
label,
|
label,
|
||||||
required,
|
required,
|
||||||
@ -41,13 +41,10 @@ export function LookupField({
|
|||||||
const { client } = useZino();
|
const { client } = useZino();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [rows, setRows] = useState<Array<Record<string, unknown>>>([]);
|
const [rows, setRows] = useState<Row[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const triggerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const panelRef = useRef<HTMLDivElement>(null);
|
|
||||||
const [pos, setPos] = useState<{ top: number; left: number; width: number } | null>(null);
|
|
||||||
|
|
||||||
const labelOf = (src: LookupValue | Record<string, unknown> | null) =>
|
const labelOf = (src: LookupValue | Row | null) =>
|
||||||
src
|
src
|
||||||
? displayCols
|
? displayCols
|
||||||
.map((c) => src[c])
|
.map((c) => src[c])
|
||||||
@ -58,7 +55,7 @@ export function LookupField({
|
|||||||
// Collapse rows that project to the same stored value — e.g. many branches
|
// Collapse rows that project to the same stored value — e.g. many branches
|
||||||
// sharing one region show "North" once. Lookups whose storage carries a
|
// sharing one region show "North" once. Lookups whose storage carries a
|
||||||
// unique id never collapse.
|
// unique id never collapse.
|
||||||
const dedupe = (list: Array<Record<string, unknown>>) => {
|
const dedupe = (list: Row[]) => {
|
||||||
const cols = storageCols.length ? storageCols : displayCols;
|
const cols = storageCols.length ? storageCols : displayCols;
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
return list.filter((row) => {
|
return list.filter((row) => {
|
||||||
@ -69,24 +66,6 @@ export function LookupField({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Anchor the portal panel under the trigger; track scroll/resize while open.
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
const place = () => {
|
|
||||||
const el = triggerRef.current;
|
|
||||||
if (!el) return;
|
|
||||||
const r = el.getBoundingClientRect();
|
|
||||||
setPos({ top: r.bottom + 4, left: r.left, width: r.width });
|
|
||||||
};
|
|
||||||
place();
|
|
||||||
window.addEventListener('scroll', place, true);
|
|
||||||
window.addEventListener('resize', place);
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('scroll', place, true);
|
|
||||||
window.removeEventListener('resize', place);
|
|
||||||
};
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
// Debounced fetch while the dropdown is open.
|
// Debounced fetch while the dropdown is open.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
@ -109,103 +88,35 @@ export function LookupField({
|
|||||||
live = false;
|
live = false;
|
||||||
clearTimeout(t);
|
clearTimeout(t);
|
||||||
};
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [open, search, templateUid, activityId, fieldId, instanceId, client]);
|
}, [open, search, templateUid, activityId, fieldId, instanceId, client]);
|
||||||
|
|
||||||
// Close on outside click — ignore clicks inside the trigger or the portal panel.
|
function pick(row: Row) {
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
const onDoc = (e: MouseEvent) => {
|
|
||||||
const t = e.target as Node;
|
|
||||||
if (triggerRef.current?.contains(t) || panelRef.current?.contains(t)) return;
|
|
||||||
setOpen(false);
|
|
||||||
};
|
|
||||||
document.addEventListener('mousedown', onDoc);
|
|
||||||
return () => document.removeEventListener('mousedown', onDoc);
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
function pick(row: Record<string, unknown>) {
|
|
||||||
const stored: LookupValue = {};
|
const stored: LookupValue = {};
|
||||||
storageCols.forEach((c) => (stored[c] = row[c] ?? null));
|
storageCols.forEach((c) => (stored[c] = row[c] ?? null));
|
||||||
displayCols.forEach((c) => {
|
displayCols.forEach((c) => {
|
||||||
if (!(c in stored)) stored[c] = row[c] ?? null;
|
if (!(c in stored)) stored[c] = row[c] ?? null;
|
||||||
});
|
});
|
||||||
onChange(stored);
|
onChange(stored);
|
||||||
setOpen(false);
|
|
||||||
setSearch('');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const current = labelOf(value);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<label className="flex flex-col gap-1.5 font-sans">
|
<PickerShell<Row>
|
||||||
{label && (
|
label={label}
|
||||||
<span className="text-sm font-medium text-muted">
|
required={required}
|
||||||
{label}
|
displayLabel={labelOf(value)}
|
||||||
{required && <span className="text-ruby-600"> *</span>}
|
items={rows}
|
||||||
</span>
|
renderItem={(row) => labelOf(row) || 'Row'}
|
||||||
)}
|
itemKey={(_row, i) => i}
|
||||||
<div
|
onPick={pick}
|
||||||
ref={triggerRef}
|
onClear={() => onChange(null)}
|
||||||
className="flex items-center gap-2 bg-card rounded-md px-3 h-[42px] border border-border-default focus-ring cursor-pointer"
|
search={search}
|
||||||
onClick={() => setOpen((o) => !o)}
|
onSearch={setSearch}
|
||||||
>
|
loading={loading}
|
||||||
<span className={cn('flex-1 truncate text-base', current ? 'text-strong' : 'text-faint')}>
|
onOpenChange={(o) => {
|
||||||
{current || 'Select…'}
|
setOpen(o);
|
||||||
</span>
|
if (!o) setSearch('');
|
||||||
{value ? (
|
}}
|
||||||
<button
|
/>
|
||||||
type="button"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onChange(null);
|
|
||||||
}}
|
|
||||||
className="text-faint hover:text-strong"
|
|
||||||
>
|
|
||||||
<X size={15} />
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<ChevronDown size={15} className="text-faint" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{open &&
|
|
||||||
pos &&
|
|
||||||
createPortal(
|
|
||||||
<div
|
|
||||||
ref={panelRef}
|
|
||||||
style={{ position: 'fixed', top: pos.top, left: pos.left, width: pos.width, zIndex: 50 }}
|
|
||||||
className="bg-card border border-border-default rounded-md shadow-lg overflow-hidden"
|
|
||||||
>
|
|
||||||
<div className="p-2 border-b border-border-subtle">
|
|
||||||
<input
|
|
||||||
autoFocus
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
|
||||||
placeholder="Search…"
|
|
||||||
className="w-full h-9 px-2.5 border border-border-default rounded-md outline-none text-sm focus:border-sunrise-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="max-h-56 overflow-auto">
|
|
||||||
{loading ? (
|
|
||||||
<div className="px-3 py-3 text-sm text-faint">Searching…</div>
|
|
||||||
) : rows.length ? (
|
|
||||||
rows.map((row, i) => (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
key={i}
|
|
||||||
onClick={() => pick(row)}
|
|
||||||
className="w-full text-left px-3 py-2.5 text-sm text-body hover:bg-slate-50 border-none bg-transparent cursor-pointer"
|
|
||||||
>
|
|
||||||
{labelOf(row) || `Row ${i + 1}`}
|
|
||||||
</button>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<div className="px-3 py-3 text-sm text-faint">No matches.</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>,
|
|
||||||
document.body,
|
|
||||||
)}
|
|
||||||
</label>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,11 +6,14 @@ import { ZinoProvider } from './api/provider';
|
|||||||
import './styles/styles.css';
|
import './styles/styles.css';
|
||||||
|
|
||||||
const baseUrl = import.meta.env.VITE_ZINO_API_URL || 'https://sandbox.getzino.in';
|
const baseUrl = import.meta.env.VITE_ZINO_API_URL || 'https://sandbox.getzino.in';
|
||||||
|
// Keep client routes under Vite's base (e.g. /lead-to-policy) so a refresh on
|
||||||
|
// an in-app route hits the preview's per-slug SPA fallback. '/' in dev.
|
||||||
|
const routerBase = import.meta.env.BASE_URL.replace(/\/$/, '');
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<ZinoProvider baseUrl={baseUrl}>
|
<ZinoProvider baseUrl={baseUrl}>
|
||||||
<BrowserRouter>
|
<BrowserRouter basename={routerBase}>
|
||||||
<App />
|
<App />
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</ZinoProvider>
|
</ZinoProvider>
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import { CheckCircle2, UserCheck } from 'lucide-react';
|
import { CheckCircle2, UserCheck } from 'lucide-react';
|
||||||
import { AIDecisionCard, Avatar, Badge, Button, Card, formatINR, Select } from '../components';
|
import { AIDecisionCard, Avatar, Badge, Button, Card, formatINR, SelectField } from '../components';
|
||||||
import { LookupField } from '../components/form';
|
import { LookupField } from '../components/form';
|
||||||
import type { LookupValue } from '../components/form';
|
import type { LookupValue } from '../components/form';
|
||||||
import { useQuery, useZino } from '../api/provider';
|
import { useQuery, useZino } from '../api/provider';
|
||||||
@ -92,7 +92,7 @@ export function AssignScreen() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="flex-1 text-sm text-faint">Select a lead at the Qualified stage.</div>
|
<div className="flex-1 text-sm text-faint">Select a lead at the Qualified stage.</div>
|
||||||
)}
|
)}
|
||||||
<Select
|
<SelectField
|
||||||
options={[
|
options={[
|
||||||
{ value: '', label: eligible.length ? 'Choose lead…' : 'No qualified leads' },
|
{ value: '', label: eligible.length ? 'Choose lead…' : 'No qualified leads' },
|
||||||
...records.map((r) => ({
|
...records.map((r) => ({
|
||||||
@ -101,8 +101,8 @@ export function AssignScreen() {
|
|||||||
})),
|
})),
|
||||||
]}
|
]}
|
||||||
value={selectedId}
|
value={selectedId}
|
||||||
onChange={(e) => {
|
onChange={(v) => {
|
||||||
setSelectedId(e.target.value);
|
setSelectedId(v);
|
||||||
setResult(null);
|
setResult(null);
|
||||||
}}
|
}}
|
||||||
className="w-[260px]"
|
className="w-[260px]"
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import { AlertTriangle, CheckCircle2, FileText, Info, ShieldCheck, UploadCloud } from 'lucide-react';
|
import { AlertTriangle, CheckCircle2, FileText, Info, ShieldCheck, UploadCloud } from 'lucide-react';
|
||||||
import { Avatar, Badge, Button, Card, DocumentUploadCard, formatINR, Input, Select } from '../components';
|
import { Avatar, Badge, Button, Card, DocumentUploadCard, formatINR, Input, SelectField } from '../components';
|
||||||
import type { BadgeTone, DocStatus, OcrField } from '../components';
|
import type { BadgeTone, DocStatus, OcrField } from '../components';
|
||||||
import { useQuery, useZino } from '../api/provider';
|
import { useQuery, useZino } from '../api/provider';
|
||||||
import { useLeads } from '../api/leads';
|
import { useLeads } from '../api/leads';
|
||||||
@ -244,7 +244,7 @@ export function DocumentsScreen() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="flex-1 text-sm text-faint">Select a lead at the Product Recommended stage.</div>
|
<div className="flex-1 text-sm text-faint">Select a lead at the Product Recommended stage.</div>
|
||||||
)}
|
)}
|
||||||
<Select
|
<SelectField
|
||||||
options={[
|
options={[
|
||||||
{ value: '', label: eligible.length ? 'Choose lead…' : 'No eligible leads' },
|
{ value: '', label: eligible.length ? 'Choose lead…' : 'No eligible leads' },
|
||||||
...records.map((r) => ({
|
...records.map((r) => ({
|
||||||
@ -253,8 +253,8 @@ export function DocumentsScreen() {
|
|||||||
})),
|
})),
|
||||||
]}
|
]}
|
||||||
value={selectedId}
|
value={selectedId}
|
||||||
onChange={(e) => {
|
onChange={(v) => {
|
||||||
setSelectedId(e.target.value);
|
setSelectedId(v);
|
||||||
setResult(null);
|
setResult(null);
|
||||||
}}
|
}}
|
||||||
className="w-[260px]"
|
className="w-[260px]"
|
||||||
@ -342,14 +342,14 @@ export function DocumentsScreen() {
|
|||||||
value={verifiedIncome}
|
value={verifiedIncome}
|
||||||
onChange={(e) => setVerifiedIncome(Number(e.target.value) || 0)}
|
onChange={(e) => setVerifiedIncome(Number(e.target.value) || 0)}
|
||||||
/>
|
/>
|
||||||
<Select
|
<SelectField
|
||||||
label="Document status"
|
label="Document status"
|
||||||
required
|
required
|
||||||
options={docStatusOptions}
|
options={docStatusOptions}
|
||||||
value={docStatus}
|
value={docStatus}
|
||||||
onChange={(e) => {
|
onChange={(v) => {
|
||||||
statusTouched.current = true;
|
statusTouched.current = true;
|
||||||
setDocStatus(e.target.value);
|
setDocStatus(v as DocStatus);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import { CheckCircle2 } from 'lucide-react';
|
import { CheckCircle2 } from 'lucide-react';
|
||||||
import { Avatar, Button, Card, formatINR, Input } from '../components';
|
import { Avatar, Button, Card, formatINR, Input, SelectField } from '../components';
|
||||||
import { useLeads } from '../api/leads';
|
import { useLeads } from '../api/leads';
|
||||||
import { useZino } from '../api/provider';
|
import { useZino } from '../api/provider';
|
||||||
import { productOf } from '../api/adapters';
|
import { productOf } from '../api/adapters';
|
||||||
@ -112,21 +112,21 @@ export function IssuePolicyScreen() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="flex-1 text-sm text-faint">Select a lead at the Underwriting stage.</div>
|
<div className="flex-1 text-sm text-faint">Select a lead at the Underwriting stage.</div>
|
||||||
)}
|
)}
|
||||||
<select
|
<SelectField
|
||||||
|
options={[
|
||||||
|
{ value: '', label: eligible.length ? 'Choose lead…' : 'No eligible leads' },
|
||||||
|
...records.map((r) => ({
|
||||||
|
value: String(r.instance_id),
|
||||||
|
label: `#${r.instance_id} ${r.lead_name ?? ''} · ${r.current_state_name ?? ''}`,
|
||||||
|
})),
|
||||||
|
]}
|
||||||
value={selectedId}
|
value={selectedId}
|
||||||
onChange={(e) => {
|
onChange={(v) => {
|
||||||
setSelectedId(e.target.value);
|
setSelectedId(v);
|
||||||
setResult(null);
|
setResult(null);
|
||||||
}}
|
}}
|
||||||
className="w-[260px] font-sans text-base text-strong border border-border-default rounded-md px-3 py-2.5 outline-none focus:border-sunrise-500"
|
className="w-[260px]"
|
||||||
>
|
/>
|
||||||
<option value="">{eligible.length ? 'Choose lead…' : 'No eligible leads'}</option>
|
|
||||||
{records.map((r) => (
|
|
||||||
<option key={r.instance_id} value={String(r.instance_id)}>
|
|
||||||
#{r.instance_id} {r.lead_name ?? ''} · {r.current_state_name ?? ''}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{result && (
|
{result && (
|
||||||
|
|||||||
@ -10,7 +10,7 @@ import {
|
|||||||
Input,
|
Input,
|
||||||
MultiSelect,
|
MultiSelect,
|
||||||
RupeeAmount,
|
RupeeAmount,
|
||||||
Select,
|
SelectField,
|
||||||
} from '../components';
|
} from '../components';
|
||||||
import { LookupField } from '../components/form';
|
import { LookupField } from '../components/form';
|
||||||
import type { LookupValue } from '../components/form';
|
import type { LookupValue } from '../components/form';
|
||||||
@ -158,7 +158,7 @@ export function RecommendScreen() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="flex-1 text-sm text-faint">Select a lead at the Meeting Scheduled stage.</div>
|
<div className="flex-1 text-sm text-faint">Select a lead at the Meeting Scheduled stage.</div>
|
||||||
)}
|
)}
|
||||||
<Select
|
<SelectField
|
||||||
options={[
|
options={[
|
||||||
{ value: '', label: eligible.length ? 'Choose lead…' : 'No eligible leads' },
|
{ value: '', label: eligible.length ? 'Choose lead…' : 'No eligible leads' },
|
||||||
...records.map((r) => ({
|
...records.map((r) => ({
|
||||||
@ -167,8 +167,8 @@ export function RecommendScreen() {
|
|||||||
})),
|
})),
|
||||||
]}
|
]}
|
||||||
value={selectedId}
|
value={selectedId}
|
||||||
onChange={(e) => {
|
onChange={(v) => {
|
||||||
setSelectedId(e.target.value);
|
setSelectedId(v);
|
||||||
setResult(null);
|
setResult(null);
|
||||||
}}
|
}}
|
||||||
className="w-[260px]"
|
className="w-[260px]"
|
||||||
@ -219,11 +219,11 @@ export function RecommendScreen() {
|
|||||||
value={sum}
|
value={sum}
|
||||||
onChange={(e) => setSum(Number(e.target.value) || 0)}
|
onChange={(e) => setSum(Number(e.target.value) || 0)}
|
||||||
/>
|
/>
|
||||||
<Select
|
<SelectField
|
||||||
label="Premium frequency"
|
label="Premium frequency"
|
||||||
options={freqOptions}
|
options={freqOptions}
|
||||||
value={freq}
|
value={freq}
|
||||||
onChange={(e) => setFreq(e.target.value)}
|
onChange={setFreq}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
label="Premium amount"
|
label="Premium amount"
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import { CheckCircle2 } from 'lucide-react';
|
import { CheckCircle2 } from 'lucide-react';
|
||||||
import { Avatar, Button, Card, formatINR, Select } from '../components';
|
import { Avatar, Button, Card, formatINR, SelectField } from '../components';
|
||||||
import { useLeads } from '../api/leads';
|
import { useLeads } from '../api/leads';
|
||||||
import { useQuery, useZino } from '../api/provider';
|
import { useQuery, useZino } from '../api/provider';
|
||||||
import { fieldFromSchema } from '../api/schema';
|
import { fieldFromSchema } from '../api/schema';
|
||||||
@ -94,7 +94,7 @@ export function UnderwritingScreen() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="flex-1 text-sm text-faint">Select a lead at the Eligibility Assessed stage.</div>
|
<div className="flex-1 text-sm text-faint">Select a lead at the Eligibility Assessed stage.</div>
|
||||||
)}
|
)}
|
||||||
<Select
|
<SelectField
|
||||||
options={[
|
options={[
|
||||||
{ value: '', label: eligible.length ? 'Choose lead…' : 'No eligible leads' },
|
{ value: '', label: eligible.length ? 'Choose lead…' : 'No eligible leads' },
|
||||||
...records.map((r) => ({
|
...records.map((r) => ({
|
||||||
@ -103,8 +103,8 @@ export function UnderwritingScreen() {
|
|||||||
})),
|
})),
|
||||||
]}
|
]}
|
||||||
value={selectedId}
|
value={selectedId}
|
||||||
onChange={(e) => {
|
onChange={(v) => {
|
||||||
setSelectedId(e.target.value);
|
setSelectedId(v);
|
||||||
setResult(null);
|
setResult(null);
|
||||||
}}
|
}}
|
||||||
className="w-[260px]"
|
className="w-[260px]"
|
||||||
@ -139,11 +139,11 @@ export function UnderwritingScreen() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<Select
|
<SelectField
|
||||||
label="Underwriting status"
|
label="Underwriting status"
|
||||||
options={statusOptions}
|
options={statusOptions}
|
||||||
value={status}
|
value={status}
|
||||||
onChange={(e) => setStatus(e.target.value)}
|
onChange={setStatus}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-faint mt-2">
|
<div className="text-xs text-faint mt-2">
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user