Compare commits
3 Commits
73f1af185f
...
ca1a014c01
| Author | SHA1 | Date | |
|---|---|---|---|
| ca1a014c01 | |||
| d01ab85cbb | |||
| 610572eafe |
@@ -3,6 +3,7 @@
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useTranslations, useLocale } from "next-intl";
|
||||
import { Link, getPathname } from "@/i18n/navigation";
|
||||
import { Logo } from "@/components/ui/logo";
|
||||
|
||||
export default function LoginPage() {
|
||||
const t = useTranslations("login");
|
||||
@@ -25,10 +26,7 @@ export default function LoginPage() {
|
||||
<div className="relative z-10 w-full max-w-sm px-5 animate-in">
|
||||
{/* Logo mark */}
|
||||
<div className="flex justify-center mb-8">
|
||||
<div className="relative h-12 w-12">
|
||||
<div className="absolute inset-0 rounded-lg bg-accent/15" />
|
||||
<div className="absolute inset-[5px] rounded-md bg-accent" />
|
||||
</div>
|
||||
<Logo className="h-14 w-auto text-accent" />
|
||||
</div>
|
||||
|
||||
<div className="bg-surface-1 rounded-2xl border border-border p-8 shadow-2xl shadow-black/40">
|
||||
|
||||
5
src/app/icon.svg
Normal file
5
src/app/icon.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="5.5 3.69 38 38" role="img" aria-label="PieCed">
|
||||
<rect x="5.5" y="3.69" width="38" height="38" rx="7" fill="#0B0F0E"/>
|
||||
<polygon points="38.5,22.69 31.5,10.566 17.5,10.566 10.5,22.69 17.5,34.814 31.5,34.814"
|
||||
fill="#10B981" stroke="#10B981" stroke-width="1.6" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 354 B |
@@ -5,6 +5,14 @@ import { useTranslations, useFormatter } from "next-intl";
|
||||
import type { PiecedTenant, TenantRequest } from "@/types";
|
||||
import { StatusBadge } from "@/components/ui/status-badge";
|
||||
import { Modal } from "@/components/ui/modal";
|
||||
import {
|
||||
applyTableView,
|
||||
nextSort,
|
||||
SearchInput,
|
||||
SortableTh,
|
||||
Pagination,
|
||||
type SortState,
|
||||
} from "@/components/admin/table-controls";
|
||||
import { formatDateTime, formatRelative } from "@/lib/format";
|
||||
import Link from "next/link";
|
||||
|
||||
@@ -53,6 +61,21 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
||||
|
||||
// Shared
|
||||
const [error, setError] = useState("");
|
||||
|
||||
// Client-side table view state (search / sort / page) for each tab.
|
||||
const [reqSearch, setReqSearch] = useState("");
|
||||
const [reqSort, setReqSort] = useState<SortState>({
|
||||
key: "created",
|
||||
dir: "desc",
|
||||
});
|
||||
const [reqPage, setReqPage] = useState(1);
|
||||
|
||||
const [tenSearch, setTenSearch] = useState("");
|
||||
const [tenSort, setTenSort] = useState<SortState>({
|
||||
key: "created",
|
||||
dir: "desc",
|
||||
});
|
||||
const [tenPage, setTenPage] = useState(1);
|
||||
// Action-scoped error — shown inside the active confirmation modal so
|
||||
// a failed approve/reject/delete surfaces next to the action that
|
||||
// caused it (and keeps the modal open), rather than as a detached
|
||||
@@ -246,6 +269,53 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
||||
|
||||
const pendingCount = requests.filter((r) => r.status === "pending").length;
|
||||
|
||||
// Derived table views: search → sort → paginate, applied client-side
|
||||
// on top of the already-fetched lists.
|
||||
const reqView = applyTableView(requests, {
|
||||
search: reqSearch,
|
||||
searchOf: (r) => [
|
||||
r.companyName,
|
||||
r.contactName,
|
||||
r.contactEmail,
|
||||
r.agentName,
|
||||
r.tenantName,
|
||||
],
|
||||
sort: reqSort,
|
||||
sortOf: (r, key) =>
|
||||
key === "company"
|
||||
? r.companyName || ""
|
||||
: key === "status"
|
||||
? r.status || ""
|
||||
: r.createdAt || "",
|
||||
page: reqPage,
|
||||
});
|
||||
|
||||
const tenView = applyTableView(tenants, {
|
||||
search: tenSearch,
|
||||
searchOf: (tn) => [
|
||||
tn.metadata.name,
|
||||
tn.spec.displayName,
|
||||
tn.spec.agentName,
|
||||
],
|
||||
sort: tenSort,
|
||||
sortOf: (tn, key) =>
|
||||
key === "name"
|
||||
? tn.spec.displayName || tn.metadata.name
|
||||
: key === "phase"
|
||||
? tn.status?.phase || "Pending"
|
||||
: tn.metadata.creationTimestamp || "",
|
||||
page: tenPage,
|
||||
});
|
||||
|
||||
const onReqSort = (key: string) => {
|
||||
setReqSort((s) => nextSort(s, key));
|
||||
setReqPage(1);
|
||||
};
|
||||
const onTenSort = (key: string) => {
|
||||
setTenSort((s) => nextSort(s, key));
|
||||
setTenPage(1);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Tab bar */}
|
||||
@@ -315,11 +385,15 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
||||
{/* ───── REQUESTS TAB ───── */}
|
||||
{tab === "requests" && (
|
||||
<>
|
||||
<div className="flex gap-1.5 mb-4 flex-wrap">
|
||||
<div className="flex items-center justify-between gap-3 mb-4 flex-wrap">
|
||||
<div className="flex gap-1.5 flex-wrap">
|
||||
{FILTERS.map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setFilter(f)}
|
||||
onClick={() => {
|
||||
setFilter(f);
|
||||
setReqPage(1);
|
||||
}}
|
||||
className={`px-3 py-1 text-xs rounded-full transition-colors ${
|
||||
filter === f
|
||||
? "bg-accent text-surface-0"
|
||||
@@ -330,6 +404,15 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<SearchInput
|
||||
value={reqSearch}
|
||||
onChange={(v) => {
|
||||
setReqSearch(v);
|
||||
setReqPage(1);
|
||||
}}
|
||||
placeholder={t("searchRequestsPlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{loadingRequests ? (
|
||||
<div className="bg-surface-1 border border-border rounded-xl p-12 text-center">
|
||||
@@ -340,15 +423,22 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
||||
<div className="bg-surface-1 border border-border rounded-xl p-12 text-center">
|
||||
<p className="text-text-secondary text-sm">{t("noRequests")}</p>
|
||||
</div>
|
||||
) : reqView.total === 0 ? (
|
||||
<div className="bg-surface-1 border border-border rounded-xl p-12 text-center">
|
||||
<p className="text-text-secondary text-sm">{t("noMatches")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-surface-1 border border-border rounded-xl overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-left">
|
||||
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted">
|
||||
{t("company")}
|
||||
</th>
|
||||
<SortableTh
|
||||
label={t("company")}
|
||||
sortKey="company"
|
||||
sort={reqSort}
|
||||
onSort={onReqSort}
|
||||
/>
|
||||
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted">
|
||||
{t("contact")}
|
||||
</th>
|
||||
@@ -358,19 +448,26 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
||||
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted hidden lg:table-cell">
|
||||
{t("packages")}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted">
|
||||
{t("status")}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted hidden md:table-cell">
|
||||
{t("submitted")}
|
||||
</th>
|
||||
<SortableTh
|
||||
label={t("status")}
|
||||
sortKey="status"
|
||||
sort={reqSort}
|
||||
onSort={onReqSort}
|
||||
/>
|
||||
<SortableTh
|
||||
label={t("submitted")}
|
||||
sortKey="created"
|
||||
sort={reqSort}
|
||||
onSort={onReqSort}
|
||||
className="hidden md:table-cell"
|
||||
/>
|
||||
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted">
|
||||
{t("actions")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{requests.map((req) => (
|
||||
{reqView.paged.map((req) => (
|
||||
<tr
|
||||
key={req.id}
|
||||
className="border-b border-border last:border-0 hover:bg-surface-2/50 transition-colors"
|
||||
@@ -506,6 +603,12 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Pagination
|
||||
page={reqView.page}
|
||||
totalPages={reqView.totalPages}
|
||||
total={reqView.total}
|
||||
onPage={setReqPage}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
@@ -543,6 +646,17 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end mb-4">
|
||||
<SearchInput
|
||||
value={tenSearch}
|
||||
onChange={(v) => {
|
||||
setTenSearch(v);
|
||||
setTenPage(1);
|
||||
}}
|
||||
placeholder={t("searchTenantsPlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{loadingTenants ? (
|
||||
<div className="bg-surface-1 border border-border rounded-xl p-12 text-center">
|
||||
<div className="h-5 w-5 border-2 border-accent border-t-transparent rounded-full animate-spin mx-auto mb-2" />
|
||||
@@ -552,37 +666,51 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
||||
<div className="bg-surface-1 border border-border rounded-xl p-12 text-center">
|
||||
<p className="text-text-secondary text-sm">{t("noTenants")}</p>
|
||||
</div>
|
||||
) : tenView.total === 0 ? (
|
||||
<div className="bg-surface-1 border border-border rounded-xl p-12 text-center">
|
||||
<p className="text-text-secondary text-sm">{t("noMatches")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-surface-1 border border-border rounded-xl overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-left">
|
||||
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted">
|
||||
{t("name")}
|
||||
</th>
|
||||
<SortableTh
|
||||
label={t("name")}
|
||||
sortKey="name"
|
||||
sort={tenSort}
|
||||
onSort={onTenSort}
|
||||
/>
|
||||
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted">
|
||||
{t("displayName")}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted">
|
||||
{t("phase")}
|
||||
</th>
|
||||
<SortableTh
|
||||
label={t("phase")}
|
||||
sortKey="phase"
|
||||
sort={tenSort}
|
||||
onSort={onTenSort}
|
||||
/>
|
||||
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted hidden md:table-cell">
|
||||
{t("packages")}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted hidden md:table-cell">
|
||||
{t("spendChf")}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted hidden md:table-cell">
|
||||
{t("created")}
|
||||
</th>
|
||||
<SortableTh
|
||||
label={t("created")}
|
||||
sortKey="created"
|
||||
sort={tenSort}
|
||||
onSort={onTenSort}
|
||||
className="hidden md:table-cell"
|
||||
/>
|
||||
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted">
|
||||
{t("actions")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tenants.map((tenant) => {
|
||||
{tenView.paged.map((tenant) => {
|
||||
const tenantSpend =
|
||||
health?.spend?.perTenant?.[tenant.metadata.name];
|
||||
return (
|
||||
@@ -680,6 +808,12 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Pagination
|
||||
page={tenView.page}
|
||||
totalPages={tenView.totalPages}
|
||||
total={tenView.total}
|
||||
onPage={setTenPage}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
190
src/components/admin/table-controls.tsx
Normal file
190
src/components/admin/table-controls.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
/**
|
||||
* Shared client-side table controls for the admin panel.
|
||||
*
|
||||
* The admin tables (requests, tenants) load their full result set into
|
||||
* state already, so search/sort/pagination are applied client-side on
|
||||
* top — no new API surface. At pilot scale the lists are small enough
|
||||
* that filtering/sorting in memory is free; if they grow past a few
|
||||
* hundred rows this is the seam to move server-side (the page/sort
|
||||
* state would become query params).
|
||||
*/
|
||||
|
||||
export const PAGE_SIZE = 15;
|
||||
|
||||
export interface SortState {
|
||||
key: string;
|
||||
dir: "asc" | "desc";
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter → sort → paginate a list. Pure function, called during render.
|
||||
* `searchOf` returns the haystack strings for a row; `sortOf` returns
|
||||
* the comparable value for the active sort key (string or number).
|
||||
*/
|
||||
export function applyTableView<T>(
|
||||
items: T[],
|
||||
opts: {
|
||||
search: string;
|
||||
searchOf: (item: T) => (string | null | undefined)[];
|
||||
sort: SortState;
|
||||
sortOf: (item: T, key: string) => string | number;
|
||||
page: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
): { paged: T[]; total: number; totalPages: number; page: number } {
|
||||
const pageSize = opts.pageSize ?? PAGE_SIZE;
|
||||
|
||||
const q = opts.search.trim().toLowerCase();
|
||||
const filtered = q
|
||||
? items.filter((it) =>
|
||||
opts
|
||||
.searchOf(it)
|
||||
.some((v) => (v ?? "").toString().toLowerCase().includes(q))
|
||||
)
|
||||
: items;
|
||||
|
||||
const sorted = [...filtered].sort((a, b) => {
|
||||
const av = opts.sortOf(a, opts.sort.key);
|
||||
const bv = opts.sortOf(b, opts.sort.key);
|
||||
const cmp =
|
||||
typeof av === "number" && typeof bv === "number"
|
||||
? av - bv
|
||||
: String(av).localeCompare(String(bv));
|
||||
return opts.sort.dir === "asc" ? cmp : -cmp;
|
||||
});
|
||||
|
||||
const total = sorted.length;
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
const page = Math.min(Math.max(1, opts.page), totalPages);
|
||||
const paged = sorted.slice((page - 1) * pageSize, page * pageSize);
|
||||
|
||||
return { paged, total, totalPages, page };
|
||||
}
|
||||
|
||||
/** Toggle helper: same key flips direction, new key starts ascending. */
|
||||
export function nextSort(current: SortState, key: string): SortState {
|
||||
if (current.key === key) {
|
||||
return { key, dir: current.dir === "asc" ? "desc" : "asc" };
|
||||
}
|
||||
return { key, dir: "asc" };
|
||||
}
|
||||
|
||||
export function SearchInput({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
placeholder: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="relative">
|
||||
<svg
|
||||
className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-text-muted pointer-events-none"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.75}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M21 21l-4.35-4.35M17 11a6 6 0 11-12 0 6 6 0 0112 0z"
|
||||
/>
|
||||
</svg>
|
||||
<input
|
||||
type="search"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="w-full sm:w-72 pl-8 pr-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SortableTh({
|
||||
label,
|
||||
sortKey,
|
||||
sort,
|
||||
onSort,
|
||||
className,
|
||||
}: {
|
||||
label: string;
|
||||
sortKey: string;
|
||||
sort: SortState;
|
||||
onSort: (key: string) => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const active = sort.key === sortKey;
|
||||
return (
|
||||
<th className={`px-4 py-3 ${className ?? ""}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSort(sortKey)}
|
||||
className={`inline-flex items-center gap-1 text-xs font-semibold uppercase tracking-wider transition-colors cursor-pointer ${
|
||||
active ? "text-text-secondary" : "text-text-muted hover:text-text-secondary"
|
||||
}`}
|
||||
aria-sort={active ? (sort.dir === "asc" ? "ascending" : "descending") : "none"}
|
||||
>
|
||||
{label}
|
||||
<span className="inline-block w-2 text-[9px]" aria-hidden="true">
|
||||
{active ? (sort.dir === "asc" ? "▲" : "▼") : ""}
|
||||
</span>
|
||||
</button>
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
||||
export function Pagination({
|
||||
page,
|
||||
totalPages,
|
||||
total,
|
||||
onPage,
|
||||
}: {
|
||||
page: number;
|
||||
totalPages: number;
|
||||
total: number;
|
||||
onPage: (p: number) => void;
|
||||
}) {
|
||||
const t = useTranslations("admin");
|
||||
if (totalPages <= 1) {
|
||||
return (
|
||||
<div className="flex items-center justify-end px-4 py-2.5 border-t border-border text-xs text-text-muted">
|
||||
<span>{t("paginationCount", { total })}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-t border-border text-xs text-text-muted gap-3">
|
||||
<span className="tabular-nums">{t("paginationCount", { total })}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onPage(page - 1)}
|
||||
disabled={page <= 1}
|
||||
className="px-2.5 py-1 rounded-md border border-border hover:bg-surface-2 disabled:opacity-40 disabled:cursor-not-allowed transition-colors cursor-pointer"
|
||||
>
|
||||
{t("paginationPrev")}
|
||||
</button>
|
||||
<span className="tabular-nums">
|
||||
{t("paginationPage", { page, total: totalPages })}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onPage(page + 1)}
|
||||
disabled={page >= totalPages}
|
||||
className="px-2.5 py-1 rounded-md border border-border hover:bg-surface-2 disabled:opacity-40 disabled:cursor-not-allowed transition-colors cursor-pointer"
|
||||
>
|
||||
{t("paginationNext")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { Link } from "@/i18n/navigation";
|
||||
import { SessionProvider } from "next-auth/react";
|
||||
import type { Session } from "next-auth";
|
||||
import { LanguageSwitcher } from "@/components/ui/language-switcher";
|
||||
import { Logo } from "@/components/ui/logo";
|
||||
|
||||
function NavBar() {
|
||||
const t = useTranslations("common");
|
||||
@@ -79,11 +80,8 @@ function NavBar() {
|
||||
{/* Logo / brand */}
|
||||
<div className="flex items-center gap-6">
|
||||
<Link href="/dashboard" className="flex items-center gap-2.5 group">
|
||||
{/* Geometric mark */}
|
||||
<div className="relative h-7 w-7">
|
||||
<div className="absolute inset-0 rounded-md bg-accent/20 group-hover:bg-accent/30 transition-colors" />
|
||||
<div className="absolute inset-[3px] rounded-sm bg-accent" />
|
||||
</div>
|
||||
{/* Brand mark */}
|
||||
<Logo className="h-7 w-auto text-accent group-hover:text-accent-dim transition-colors" />
|
||||
<span className="font-display text-base font-semibold tracking-tight text-text-primary">
|
||||
{t("appName")}
|
||||
</span>
|
||||
|
||||
@@ -139,7 +139,7 @@ export function ConnectPanel({
|
||||
<button
|
||||
type="button"
|
||||
onClick={reopen}
|
||||
className="text-xs font-medium text-accent hover:text-accent-dim transition-colors cursor-pointer"
|
||||
className="shrink-0 inline-flex items-center rounded-md border border-border px-2.5 py-1 text-xs font-medium text-accent hover:bg-surface-2 hover:border-accent/40 transition-colors cursor-pointer"
|
||||
>
|
||||
{t("show")}
|
||||
</button>
|
||||
@@ -156,7 +156,7 @@ export function ConnectPanel({
|
||||
<button
|
||||
type="button"
|
||||
onClick={dismiss}
|
||||
className="shrink-0 inline-flex items-center gap-1 text-xs font-medium text-text-muted hover:text-text-secondary transition-colors cursor-pointer"
|
||||
className="shrink-0 inline-flex items-center gap-1.5 rounded-md border border-accent/40 bg-accent/10 px-2.5 py-1 text-xs font-medium text-accent hover:bg-accent/20 hover:border-accent/60 transition-colors cursor-pointer"
|
||||
>
|
||||
<svg
|
||||
className="h-3.5 w-3.5"
|
||||
|
||||
83
src/components/ui/logo.tsx
Normal file
83
src/components/ui/logo.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* PieCed honeycomb mark.
|
||||
*
|
||||
* Six flat-top hexagons: H1/H4 solid, H2/H3 outline, H5/H6 partial.
|
||||
* All strokes/fills use `currentColor` so the mark inherits its colour
|
||||
* from the surrounding text colour (e.g. `text-accent`) and adapts to
|
||||
* hover/theme without editing the SVG. Original brand emerald is
|
||||
* #10B981, which the accent token matches.
|
||||
*
|
||||
* viewBox is portrait (70×106); size it by height and let width follow
|
||||
* (`h-7 w-auto`).
|
||||
*/
|
||||
export function Logo({
|
||||
className,
|
||||
title = "PieCed IT",
|
||||
}: {
|
||||
className?: string;
|
||||
title?: string;
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 70 106"
|
||||
className={className}
|
||||
role="img"
|
||||
aria-label={title}
|
||||
fill="none"
|
||||
>
|
||||
<title>{title}</title>
|
||||
{/* H1 — solid, top-left */}
|
||||
<polygon
|
||||
points="38.5,22.69 31.5,10.566 17.5,10.566 10.5,22.69 17.5,34.814 31.5,34.814"
|
||||
fill="currentColor"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
{/* H2 — outline, upper-right */}
|
||||
<polygon
|
||||
points="59.5,34.814 52.5,22.69 38.5,22.69 31.5,34.814 38.5,46.938 52.5,46.938"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
{/* H3 — outline, mid-left */}
|
||||
<polygon
|
||||
points="38.5,46.938 31.5,34.814 17.5,34.814 10.5,46.938 17.5,59.062 31.5,59.062"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
{/* H4 — solid, mid-right */}
|
||||
<polygon
|
||||
points="59.5,59.062 52.5,46.938 38.5,46.938 31.5,59.062 38.5,71.186 52.5,71.186"
|
||||
fill="currentColor"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
{/* H5 — partial, lower-left */}
|
||||
<polyline
|
||||
points="31.5,83.31 38.5,71.186 31.5,59.062 17.5,59.062 10.5,71.186"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
{/* H6 — partial, lower-right */}
|
||||
<polyline
|
||||
points="59.5,83.31 52.5,71.186 38.5,71.186 31.5,83.31 38.5,95.434"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -436,7 +436,14 @@
|
||||
"approveTitle": "Anfrage genehmigen?",
|
||||
"approveWarning": "Dadurch wird die Infrastruktur des Mandanten bereitgestellt, die Einrichtungsgebühr berechnet und der Kunde benachrichtigt. Bitte prüfen Sie die Angaben, bevor Sie fortfahren.",
|
||||
"approveReapproveWarning": "Dies genehmigt eine zuvor abgelehnte Anfrage erneut: Die Infrastruktur des Mandanten wird bereitgestellt, die Einrichtungsgebühr berechnet und der Kunde benachrichtigt.",
|
||||
"confirmApprove": "Genehmigen & bereitstellen"
|
||||
"confirmApprove": "Genehmigen & bereitstellen",
|
||||
"searchRequestsPlaceholder": "Anfragen suchen…",
|
||||
"searchTenantsPlaceholder": "Mandanten suchen…",
|
||||
"paginationPrev": "Zurück",
|
||||
"paginationNext": "Weiter",
|
||||
"paginationPage": "Seite {page} von {total}",
|
||||
"paginationCount": "{total} gesamt",
|
||||
"noMatches": "Keine Treffer."
|
||||
},
|
||||
"channelUsers": {
|
||||
"title": "Autorisierte Benutzer",
|
||||
@@ -847,7 +854,8 @@
|
||||
"orgsPayByInvoiceOn": "ein",
|
||||
"orgsPayByInvoiceOff": "aus",
|
||||
"orgsAutoChargeOn": "ein",
|
||||
"orgsAutoChargeOff": "aus"
|
||||
"orgsAutoChargeOff": "aus",
|
||||
"newInvoiceOrgNoMatches": "Keine passenden Kunden."
|
||||
},
|
||||
"skillCostDialog": {
|
||||
"title": "Aktivierungskosten bestätigen",
|
||||
|
||||
@@ -436,7 +436,14 @@
|
||||
"approveTitle": "Approve request?",
|
||||
"approveWarning": "This provisions the tenant's infrastructure, charges the setup fee, and notifies the customer. Check the request details are correct before continuing.",
|
||||
"approveReapproveWarning": "This re-approves a previously rejected request: it provisions the tenant's infrastructure, charges the setup fee, and notifies the customer.",
|
||||
"confirmApprove": "Approve & provision"
|
||||
"confirmApprove": "Approve & provision",
|
||||
"searchRequestsPlaceholder": "Search requests…",
|
||||
"searchTenantsPlaceholder": "Search tenants…",
|
||||
"paginationPrev": "Previous",
|
||||
"paginationNext": "Next",
|
||||
"paginationPage": "Page {page} of {total}",
|
||||
"paginationCount": "{total} total",
|
||||
"noMatches": "No matches."
|
||||
},
|
||||
"channelUsers": {
|
||||
"title": "Authorized Users",
|
||||
@@ -847,7 +854,8 @@
|
||||
"orgsPayByInvoiceOn": "on",
|
||||
"orgsPayByInvoiceOff": "off",
|
||||
"orgsAutoChargeOn": "on",
|
||||
"orgsAutoChargeOff": "off"
|
||||
"orgsAutoChargeOff": "off",
|
||||
"newInvoiceOrgNoMatches": "No matching customers."
|
||||
},
|
||||
"skillCostDialog": {
|
||||
"title": "Confirm activation cost",
|
||||
|
||||
@@ -436,7 +436,14 @@
|
||||
"approveTitle": "Approuver la demande ?",
|
||||
"approveWarning": "Cela provisionne l'infrastructure du locataire, facture les frais d'installation et notifie le client. Vérifiez l'exactitude des détails de la demande avant de continuer.",
|
||||
"approveReapproveWarning": "Ceci réapprouve une demande précédemment rejetée : l'infrastructure du locataire est provisionnée, les frais d'installation sont facturés et le client est notifié.",
|
||||
"confirmApprove": "Approuver et provisionner"
|
||||
"confirmApprove": "Approuver et provisionner",
|
||||
"searchRequestsPlaceholder": "Rechercher des demandes…",
|
||||
"searchTenantsPlaceholder": "Rechercher des locataires…",
|
||||
"paginationPrev": "Précédent",
|
||||
"paginationNext": "Suivant",
|
||||
"paginationPage": "Page {page} sur {total}",
|
||||
"paginationCount": "{total} au total",
|
||||
"noMatches": "Aucun résultat."
|
||||
},
|
||||
"channelUsers": {
|
||||
"title": "Utilisateurs autorisés",
|
||||
@@ -847,7 +854,8 @@
|
||||
"orgsPayByInvoiceOn": "actif",
|
||||
"orgsPayByInvoiceOff": "inactif",
|
||||
"orgsAutoChargeOn": "actif",
|
||||
"orgsAutoChargeOff": "inactif"
|
||||
"orgsAutoChargeOff": "inactif",
|
||||
"newInvoiceOrgNoMatches": "Aucun client correspondant."
|
||||
},
|
||||
"skillCostDialog": {
|
||||
"title": "Confirmer le coût d'activation",
|
||||
|
||||
@@ -436,7 +436,14 @@
|
||||
"approveTitle": "Approvare la richiesta?",
|
||||
"approveWarning": "Questa operazione effettua il provisioning dell'infrastruttura del tenant, addebita il costo di attivazione e notifica il cliente. Verifica che i dettagli della richiesta siano corretti prima di continuare.",
|
||||
"approveReapproveWarning": "Questo riapprova una richiesta precedentemente rifiutata: effettua il provisioning dell'infrastruttura del tenant, addebita il costo di attivazione e notifica il cliente.",
|
||||
"confirmApprove": "Approva e avvia provisioning"
|
||||
"confirmApprove": "Approva e avvia provisioning",
|
||||
"searchRequestsPlaceholder": "Cerca richieste…",
|
||||
"searchTenantsPlaceholder": "Cerca tenant…",
|
||||
"paginationPrev": "Precedente",
|
||||
"paginationNext": "Successivo",
|
||||
"paginationPage": "Pagina {page} di {total}",
|
||||
"paginationCount": "{total} totali",
|
||||
"noMatches": "Nessun risultato."
|
||||
},
|
||||
"channelUsers": {
|
||||
"title": "Utenti autorizzati",
|
||||
@@ -847,7 +854,8 @@
|
||||
"orgsPayByInvoiceOn": "attivo",
|
||||
"orgsPayByInvoiceOff": "disattivo",
|
||||
"orgsAutoChargeOn": "attivo",
|
||||
"orgsAutoChargeOff": "disattivo"
|
||||
"orgsAutoChargeOff": "disattivo",
|
||||
"newInvoiceOrgNoMatches": "Nessun cliente corrispondente."
|
||||
},
|
||||
"skillCostDialog": {
|
||||
"title": "Confermi costi di attivazione",
|
||||
|
||||
Reference in New Issue
Block a user