feat(admin): add search, sorting and pagination to admin tables
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Card } from "@/components/ui/card";
|
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">
|
<label className="text-xs uppercase tracking-wider text-text-muted">
|
||||||
{t("newInvoiceOrgLabel")}
|
{t("newInvoiceOrgLabel")}
|
||||||
</label>
|
</label>
|
||||||
<select
|
<OrgCombobox
|
||||||
|
orgs={orgs}
|
||||||
value={orgId}
|
value={orgId}
|
||||||
onChange={(e) => onOrgChange(e.target.value)}
|
onChange={onOrgChange}
|
||||||
className="px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
|
placeholder={t("newInvoiceOrgPlaceholder")}
|
||||||
>
|
noBillingLabel={t("newInvoiceOrgNoBilling")}
|
||||||
<option value="">{t("newInvoiceOrgPlaceholder")}</option>
|
noMatchesLabel={t("newInvoiceOrgNoMatches")}
|
||||||
{orgs.map((o) => (
|
/>
|
||||||
<option
|
|
||||||
key={o.zitadelOrgId}
|
|
||||||
value={o.zitadelOrgId}
|
|
||||||
disabled={!o.hasBillingAddress}
|
|
||||||
>
|
|
||||||
{o.companyName ?? o.zitadelOrgId}
|
|
||||||
{!o.hasBillingAddress
|
|
||||||
? ` (${t("newInvoiceOrgNoBilling")})`
|
|
||||||
: ""}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
{selected && !selected.hasBillingAddress && (
|
{selected && !selected.hasBillingAddress && (
|
||||||
<p className="text-xs text-error mt-1">
|
<p className="text-xs text-error mt-1">
|
||||||
{t("newInvoiceOrgBillingMissing")}
|
{t("newInvoiceOrgBillingMissing")}
|
||||||
@@ -164,3 +153,138 @@ export function NewInvoiceForm({ orgs }: Props) {
|
|||||||
</Card>
|
</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