diff --git a/src/components/cases/collections-tab.tsx b/src/components/cases/collections-tab.tsx index fac886d..a0298cb 100644 --- a/src/components/cases/collections-tab.tsx +++ b/src/components/cases/collections-tab.tsx @@ -20,6 +20,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { SearchableSelect } from "@/components/ui/searchable-select"; import { Table, TableBody, @@ -1147,19 +1148,17 @@ function AddCollectionDialog({ : "All homeowners on this HOA already have a collection on this matter."}

) : ( - + { + const label = `${h.last_name}, ${h.first_name}${h.unit_number ? ` — Unit ${h.unit_number}` : ""}`; + return { value: h.id, label, keywords: `${h.first_name} ${h.last_name} ${h.unit_number ?? ""}` }; + })} + /> )}
diff --git a/src/components/cases/expenses-tab.tsx b/src/components/cases/expenses-tab.tsx index 193b6b4..df6aae0 100644 --- a/src/components/cases/expenses-tab.tsx +++ b/src/components/cases/expenses-tab.tsx @@ -17,6 +17,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { SearchableSelect } from "@/components/ui/searchable-select"; interface FeeItem { id: string; @@ -173,7 +174,7 @@ export function CaseExpensesTab({ caseId }: { caseId: string }) { {fees.length > 0 && (
- + placeholder="Pick from the fee schedule…" + searchPlaceholder="Search fee items…" + emptyText="No fee items found." + options={fees.map((f) => ({ + value: f.id, + label: `${f.name} — ${formatCurrency(Number(f.amount))}`, + keywords: `${f.name} ${f.description ?? ""}`, + }))} + />
)}
diff --git a/src/components/cases/time-tab.tsx b/src/components/cases/time-tab.tsx index 79d5c14..2c0b722 100644 --- a/src/components/cases/time-tab.tsx +++ b/src/components/cases/time-tab.tsx @@ -19,6 +19,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { SearchableSelect } from "@/components/ui/searchable-select"; import { NewFeeItemDialog } from "@/components/fees/new-fee-item-dialog"; interface FeeItem { @@ -203,14 +204,12 @@ export function CaseTimeTab({ caseRecord, onInvoice }: CaseTimeTabProps) {
{fees.length > 0 && ( - + placeholder="Pick a rate from the fee schedule…" + searchPlaceholder="Search fee items…" + emptyText="No fee items found." + options={fees.map((f) => ({ + value: f.id, + label: `${f.name}${Number(f.amount) > 0 ? ` — ${formatCurrency(Number(f.amount))}/hr` : " — uses your default rate"}`, + keywords: `${f.name} ${f.description ?? ""}`, + }))} + /> )} diff --git a/src/components/forms/client-case-picker.tsx b/src/components/forms/client-case-picker.tsx index 1ca4bbe..89dd0cd 100644 --- a/src/components/forms/client-case-picker.tsx +++ b/src/components/forms/client-case-picker.tsx @@ -1,12 +1,6 @@ import { useEffect, useMemo, useState } from "react"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; import { Label } from "@/components/ui/label"; +import { SearchableSelect } from "@/components/ui/searchable-select"; import { fetchClients, fetchAccessibleCases, @@ -42,56 +36,43 @@ export function ClientCasePicker({
- + placeholder="Select a client" + searchPlaceholder="Search clients…" + emptyText="No clients found." + options={clients.map((c) => ({ value: c.id, label: c.name, keywords: c.name }))} + />
- + placeholder={clientId ? "Select case" : "Pick client first"} + searchPlaceholder="Search cases…" + emptyText={clientId ? "No cases for this client." : "Pick a client first."} + options={filteredCases.map((c) => ({ + value: c.id, + label: `${c.case_number} — ${c.title}`, + keywords: `${c.case_number} ${c.title}`, + render: ( + + {c.case_number} + {c.title} + + ), + }))} + />
); diff --git a/src/components/forms/form-fields-fill-panel.tsx b/src/components/forms/form-fields-fill-panel.tsx index 2655d68..dcc3f48 100644 --- a/src/components/forms/form-fields-fill-panel.tsx +++ b/src/components/forms/form-fields-fill-panel.tsx @@ -9,6 +9,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { SearchableSelect } from "@/components/ui/searchable-select"; import { fetchClients, fetchHomeowners, @@ -154,25 +155,17 @@ export function FormFieldsFillPanel({ return (
{labelEl} - + placeholder="Select client" + searchPlaceholder="Search clients…" + emptyText="No clients found." + options={clients.map((c) => ({ value: c.name, label: c.name, keywords: c.name }))} + />
); } @@ -182,42 +175,30 @@ export function FormFieldsFillPanel({ return (
{labelEl} - - diff --git a/src/components/forms/form-pickers.tsx b/src/components/forms/form-pickers.tsx index d0048d5..d62b105 100644 --- a/src/components/forms/form-pickers.tsx +++ b/src/components/forms/form-pickers.tsx @@ -1,12 +1,6 @@ import { useEffect, useState } from "react"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; import { Label } from "@/components/ui/label"; +import { SearchableSelect } from "@/components/ui/searchable-select"; import { fetchClients, fetchHomeowners, @@ -46,49 +40,37 @@ export function ClientHomeownerPicker({
- + placeholder="Select a client" + searchPlaceholder="Search clients…" + emptyText="No clients found." + options={clients.map((c) => ({ value: c.id, label: c.name, keywords: c.name }))} + />
{showHomeowner && (
- + placeholder={clientId ? "Select homeowner" : "Pick client first"} + searchPlaceholder="Search homeowners…" + emptyText="No homeowners found." + options={homeowners.map((h) => { + const label = `${h.last_name}, ${h.first_name}${h.unit_number ? ` — Unit ${h.unit_number}` : ""}`; + return { value: h.id, label, keywords: `${h.first_name} ${h.last_name} ${h.unit_number ?? ""}` }; + })} + />
)}
diff --git a/src/components/forms/letter-generator.tsx b/src/components/forms/letter-generator.tsx index 9059b3e..bb9754a 100644 --- a/src/components/forms/letter-generator.tsx +++ b/src/components/forms/letter-generator.tsx @@ -11,6 +11,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { SearchableSelect } from "@/components/ui/searchable-select"; import { FileDown } from "lucide-react"; import { applyVariables, @@ -348,22 +349,21 @@ export function LetterGenerator() {
- + placeholder="Link to a case" + searchPlaceholder="Search cases…" + emptyText="No cases found." + options={[ + { value: "__none", label: "— No case —" }, + ...cases.map((c) => ({ + value: c.id, + label: `${c.case_number} — ${c.title}`, + keywords: `${c.case_number} ${c.title}`, + })), + ]} + />
- + ({ + value: c.id, + label: `★ ${c.name}${c.company ? ` — ${c.company}` : ""}`, + keywords: `${c.name} ${c.company ?? ""}`, + })), + ...sortedContacts.others.map((c) => ({ + value: c.id, + label: `${c.name}${c.company ? ` — ${c.company}` : ""}`, + keywords: `${c.name} ${c.company ?? ""}`, + })), + ]} + />
diff --git a/src/components/quick-add/quick-add.tsx b/src/components/quick-add/quick-add.tsx index d65b38e..5e88b6c 100644 --- a/src/components/quick-add/quick-add.tsx +++ b/src/components/quick-add/quick-add.tsx @@ -15,13 +15,7 @@ import { DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; +import { SearchableSelect } from "@/components/ui/searchable-select"; import { Clock, Loader2, Plus, Receipt as ReceiptIcon } from "lucide-react"; import { toast } from "sonner"; import { roundToTenth } from "@/lib/timer"; @@ -181,40 +175,41 @@ export function QuickAddTime() {
- + placeholder="Select" + searchPlaceholder="Search clients…" + emptyText="No clients found." + options={clients.map((c) => ({ value: c.id, label: c.name, keywords: c.name }))} + />
- + + ), + }))} + />
{fees.length > 0 && (
- + placeholder="Pick from fee schedule…" + searchPlaceholder="Search fee items…" + emptyText="No fee items." + options={fees.map((f) => ({ + value: f.id, + label: `${f.name}${Number(f.amount) > 0 ? ` — $${Number(f.amount).toFixed(2)}/hr` : " — uses your default rate"}`, + keywords: f.name, + }))} + />
)}
@@ -366,40 +357,41 @@ export function QuickAddExpense() {
- + placeholder="Select" + searchPlaceholder="Search clients…" + emptyText="No clients found." + options={clients.map((c) => ({ value: c.id, label: c.name, keywords: c.name }))} + />
- + + ), + }))} + />
{fees.length > 0 && (
- + placeholder="Pick from fee schedule…" + searchPlaceholder="Search fee items…" + emptyText="No fee items." + options={fees.map((f) => ({ + value: f.id, + label: `${f.name} — $${Number(f.amount).toFixed(2)}`, + keywords: f.name, + }))} + />
)}
diff --git a/src/components/tasks/new-task-dialog.tsx b/src/components/tasks/new-task-dialog.tsx index 3b2b092..15058ed 100644 --- a/src/components/tasks/new-task-dialog.tsx +++ b/src/components/tasks/new-task-dialog.tsx @@ -18,6 +18,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { SearchableSelect } from "@/components/ui/searchable-select"; import { Popover, PopoverContent, @@ -213,46 +214,42 @@ export function NewTaskDialog({
- + placeholder="No client" + searchPlaceholder="Search clients…" + emptyText="No clients found." + options={[ + { value: "_none", label: "No client" }, + ...clients.map((c) => ({ value: c.id, label: c.name, keywords: c.name })), + ]} + />
- + setCaseId(v === "_none" ? null : v)} + placeholder="No case" + searchPlaceholder="Search cases…" + emptyText="No cases found." + options={[ + { value: "_none", label: "No case" }, + ...filteredCases.map((c) => ({ + value: c.id, + label: `${c.case_number} — ${c.title}`, + keywords: `${c.case_number} ${c.title}`, + })), + ]} + />
diff --git a/src/components/timer/header-timer.tsx b/src/components/timer/header-timer.tsx index 98031ba..efbe94c 100644 --- a/src/components/timer/header-timer.tsx +++ b/src/components/timer/header-timer.tsx @@ -12,13 +12,7 @@ import { PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; +import { SearchableSelect } from "@/components/ui/searchable-select"; import { Pause, Play, Square, Timer as TimerIcon, Loader2, X, PlusCircle } from "lucide-react"; import { toast } from "sonner"; import { NewFeeItemDialog } from "@/components/fees/new-fee-item-dialog"; @@ -209,44 +203,37 @@ export function HeaderTimer() {
- + placeholder="Select client" + searchPlaceholder="Search clients…" + emptyText="No clients found." + options={clients.map((c) => ({ value: c.id, label: c.name, keywords: c.name }))} + />
- + placeholder={timer.clientId ? "Select case" : "Pick a client first"} + searchPlaceholder="Search cases…" + emptyText="No cases for this client." + options={filteredCases.map((c) => ({ + value: c.id, + label: `${c.case_number} — ${c.title}`, + keywords: `${c.case_number} ${c.title}`, + render: ( + + {c.case_number} + {c.title} + + ), + }))} + />
@@ -263,26 +250,27 @@ export function HeaderTimer() {
{feeItems.length > 0 ? ( - + + ), + }))} + /> ) : (

No saved items yet. Click "Add new item" to save one for reuse. diff --git a/src/components/ui/searchable-select.tsx b/src/components/ui/searchable-select.tsx new file mode 100644 index 0000000..de7a756 --- /dev/null +++ b/src/components/ui/searchable-select.tsx @@ -0,0 +1,127 @@ +import * as React from "react"; +import { Check, ChevronsUpDown } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; + +export interface SearchableSelectOption { + value: string; + /** Visible label in the selected trigger */ + label: string; + /** Custom render for the option row (defaults to label) */ + render?: React.ReactNode; + /** Extra text used for fuzzy matching (case number, email, etc.) */ + keywords?: string; + disabled?: boolean; +} + +interface SearchableSelectProps { + value?: string | null; + onValueChange: (value: string) => void; + options: SearchableSelectOption[]; + placeholder?: string; + searchPlaceholder?: string; + emptyText?: string; + disabled?: boolean; + className?: string; + triggerClassName?: string; + /** Width of popover content. Default: trigger width via "w-[--radix-popover-trigger-width]" */ + contentClassName?: string; + /** Optional element rendered above the list (e.g. "Add new") */ + footer?: React.ReactNode; +} + +export function SearchableSelect({ + value, + onValueChange, + options, + placeholder = "Select…", + searchPlaceholder = "Search…", + emptyText = "No results.", + disabled, + className, + triggerClassName, + contentClassName, + footer, +}: SearchableSelectProps) { + const [open, setOpen] = React.useState(false); + const selected = options.find((o) => o.value === value); + + return ( +

+ + + + + + { + const opt = options.find((o) => o.value === itemValue); + if (!opt) return 0; + const hay = `${opt.label} ${opt.keywords ?? ""}`.toLowerCase(); + return hay.includes(search.toLowerCase()) ? 1 : 0; + }} + > + + + {emptyText} + + {options.map((opt) => ( + { + onValueChange(v); + setOpen(false); + }} + > + + {opt.render ?? opt.label} + + ))} + + {footer} + + + + +
+ ); +} diff --git a/src/routes/cases.new.tsx b/src/routes/cases.new.tsx index d685840..156db0e 100644 --- a/src/routes/cases.new.tsx +++ b/src/routes/cases.new.tsx @@ -9,6 +9,7 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { SearchableSelect } from "@/components/ui/searchable-select"; import { supabase } from "@/integrations/supabase/client"; import { useAuth } from "@/lib/auth"; import { ArrowLeft, Loader2 } from "lucide-react"; @@ -111,13 +112,17 @@ function NewCase() {
- + setForm({ ...form, client_id: v === "__none" ? "" : v })} + placeholder="No client" + searchPlaceholder="Search clients…" + emptyText="No clients found." + options={[ + { value: "__none", label: "No client (attach later)" }, + ...clients.map((c) => ({ value: c.id, label: c.name, keywords: c.name })), + ]} + />
diff --git a/src/routes/documents.pleading.new.tsx b/src/routes/documents.pleading.new.tsx index 897c953..24a59f1 100644 --- a/src/routes/documents.pleading.new.tsx +++ b/src/routes/documents.pleading.new.tsx @@ -10,6 +10,7 @@ import { Textarea } from "@/components/ui/textarea"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { SearchableSelect } from "@/components/ui/searchable-select"; import { Switch } from "@/components/ui/switch"; import { FL_CIRCUITS } from "@/lib/florida"; import { Packer } from "docx"; @@ -331,7 +332,7 @@ function PleadingNewPage() {
- + placeholder="None" + searchPlaceholder="Search clients…" + emptyText="No clients found." + options={[ + { value: "__none__", label: "— None —" }, + ...clientOptions.map((c) => ({ value: c.id, label: c.name, keywords: c.name })), + ]} + />
- + setCaseId(v === "__none__" ? "" : v)} + placeholder="None" + searchPlaceholder="Search cases…" + emptyText="No cases found." + options={[ + { value: "__none__", label: "— None —" }, + ...filteredCases.map((k) => ({ + value: k.id, + label: `${k.case_number} — ${k.title}`, + keywords: `${k.case_number} ${k.title}`, + })), + ]} + />
diff --git a/src/routes/status.index.tsx b/src/routes/status.index.tsx index 1b40a95..37fd7aa 100644 --- a/src/routes/status.index.tsx +++ b/src/routes/status.index.tsx @@ -14,6 +14,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { SearchableSelect } from "@/components/ui/searchable-select"; import { supabase } from "@/integrations/supabase/client"; import { useAuth } from "@/lib/auth"; import { Loader2, Send, ExternalLink } from "lucide-react"; @@ -109,19 +110,18 @@ function StatusUpdatesPage() {
- + ({ + value: c.id, + label: `${c.case_number} — ${c.title}${c.client?.name ? ` · ${c.client.name}` : ""}`, + keywords: `${c.case_number} ${c.title} ${c.client?.name ?? ""}`, + }))} + /> {selectedCase && (