feat(admin): add search, sorting and pagination to admin tables
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card } from "@/components/ui/card";
|
||||
@@ -104,25 +104,14 @@ export function NewInvoiceForm({ orgs }: Props) {
|
||||
<label className="text-xs uppercase tracking-wider text-text-muted">
|
||||
{t("newInvoiceOrgLabel")}
|
||||
</label>
|
||||
<select
|
||||
<OrgCombobox
|
||||
orgs={orgs}
|
||||
value={orgId}
|
||||
onChange={(e) => onOrgChange(e.target.value)}
|
||||
className="px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
|
||||
>
|
||||
<option value="">{t("newInvoiceOrgPlaceholder")}</option>
|
||||
{orgs.map((o) => (
|
||||
<option
|
||||
key={o.zitadelOrgId}
|
||||
value={o.zitadelOrgId}
|
||||
disabled={!o.hasBillingAddress}
|
||||
>
|
||||
{o.companyName ?? o.zitadelOrgId}
|
||||
{!o.hasBillingAddress
|
||||
? ` (${t("newInvoiceOrgNoBilling")})`
|
||||
: ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
onChange={onOrgChange}
|
||||
placeholder={t("newInvoiceOrgPlaceholder")}
|
||||
noBillingLabel={t("newInvoiceOrgNoBilling")}
|
||||
noMatchesLabel={t("newInvoiceOrgNoMatches")}
|
||||
/>
|
||||
{selected && !selected.hasBillingAddress && (
|
||||
<p className="text-xs text-error mt-1">
|
||||
{t("newInvoiceOrgBillingMissing")}
|
||||
@@ -164,3 +153,138 @@ export function NewInvoiceForm({ orgs }: Props) {
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Searchable single-select for the billing org. Replaces a plain
|
||||
* <select> that would become unusable once the customer list grows:
|
||||
* type to filter by company name or org id, arrow keys to move, Enter
|
||||
* to pick. Orgs without a billing snapshot stay selectable but are
|
||||
* flagged — selecting one surfaces the existing "billing missing"
|
||||
* warning and keeps the submit button disabled.
|
||||
*/
|
||||
function OrgCombobox({
|
||||
orgs,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
noBillingLabel,
|
||||
noMatchesLabel,
|
||||
}: {
|
||||
orgs: OrgEntry[];
|
||||
value: string;
|
||||
onChange: (orgId: string) => void;
|
||||
placeholder: string;
|
||||
noBillingLabel: string;
|
||||
noMatchesLabel: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const [hi, setHi] = useState(0);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const selected = orgs.find((o) => o.zitadelOrgId === value) || null;
|
||||
const display = selected ? selected.companyName ?? selected.zitadelOrgId : "";
|
||||
|
||||
// Close on outside click so the dropdown doesn't linger.
|
||||
useEffect(() => {
|
||||
const onDoc = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", onDoc);
|
||||
return () => document.removeEventListener("mousedown", onDoc);
|
||||
}, []);
|
||||
|
||||
const q = query.trim().toLowerCase();
|
||||
const filtered = q
|
||||
? orgs.filter(
|
||||
(o) =>
|
||||
(o.companyName ?? "").toLowerCase().includes(q) ||
|
||||
o.zitadelOrgId.toLowerCase().includes(q)
|
||||
)
|
||||
: orgs;
|
||||
|
||||
const choose = (o: OrgEntry) => {
|
||||
onChange(o.zitadelOrgId);
|
||||
setOpen(false);
|
||||
setQuery("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={open ? query : display}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
setOpen(true);
|
||||
setHi(0);
|
||||
}}
|
||||
onFocus={() => {
|
||||
setOpen(true);
|
||||
setQuery("");
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setOpen(true);
|
||||
setHi((h) => Math.min(h + 1, filtered.length - 1));
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setHi((h) => Math.max(h - 1, 0));
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
if (open && filtered[hi]) choose(filtered[hi]);
|
||||
} else if (e.key === "Escape") {
|
||||
setOpen(false);
|
||||
}
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
aria-autocomplete="list"
|
||||
className="w-full px-3 py-2 rounded-md border border-border bg-surface-2 text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors"
|
||||
/>
|
||||
{open && (
|
||||
<ul
|
||||
role="listbox"
|
||||
className="absolute z-20 mt-1 max-h-64 w-full overflow-auto rounded-md border border-border bg-surface-1 shadow-xl py-1"
|
||||
>
|
||||
{filtered.length === 0 ? (
|
||||
<li className="px-3 py-2 text-xs text-text-muted">
|
||||
{noMatchesLabel}
|
||||
</li>
|
||||
) : (
|
||||
filtered.map((o, i) => (
|
||||
<li
|
||||
key={o.zitadelOrgId}
|
||||
role="option"
|
||||
aria-selected={o.zitadelOrgId === value}
|
||||
onMouseEnter={() => setHi(i)}
|
||||
// mousedown (not click) so selection runs before the
|
||||
// input's blur closes the list.
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
choose(o);
|
||||
}}
|
||||
className={`px-3 py-2 text-sm cursor-pointer flex items-center justify-between gap-2 ${
|
||||
i === hi ? "bg-surface-3" : "hover:bg-surface-2"
|
||||
}`}
|
||||
>
|
||||
<span className="truncate text-text-primary">
|
||||
{o.companyName ?? o.zitadelOrgId}
|
||||
</span>
|
||||
{!o.hasBillingAddress && (
|
||||
<span className="text-[10px] text-error shrink-0">
|
||||
{noBillingLabel}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user