Scaffolds the Next.js 16 / TypeScript strict / Tailwind v3 app per NEXTJS_REBUILD_SUPERPROMPT.md, and implements the Foundation slice from the Phase 1 plan: - Corrected Supabase schema (supabase/schema.sql): org units, employees, history, positions, hire drafts, saved reports, audit log, reorg scenarios, role-based profiles, salary-masking view, RLS policies, auto-derivation triggers, position-number generator. - Seed script (supabase/seed.ts): ~800 realistic Austrian employees across 9 divisions / 16 departments / 35 teams, history, 8 open positions, and hr_admin/manager test accounts. - Supabase clients (lib/supabase/*), design tokens (tailwind.config.ts), format/color helpers (lib/format.ts, lib/colors.ts). - Shared UI kit (components/ui): Avatar, StatusChip, Toast, Modal, SlideOver, SegmentedControl, Lookup. - Auth (login page, Server Actions) and proxy.ts (Next 16's replacement for middleware) guarding the authenticated route group. - Shell (Sidebar, Topbar, NewHireButton stub) and the Dashboard page, reading live data via employees_directory. Employees list/detail, hire wizard, action panels, org chart, positions, reports, and audit log are deferred to later phases per the plan.
105 lines
3.3 KiB
TypeScript
105 lines
3.3 KiB
TypeScript
"use client";
|
|
|
|
import { Search, X } from "lucide-react";
|
|
import { useEffect, useRef, useState, type ReactNode } from "react";
|
|
|
|
type LookupProps<T> = {
|
|
placeholder?: string;
|
|
onSearch: (query: string) => Promise<T[]>;
|
|
renderResult: (item: T) => ReactNode;
|
|
onSelect: (item: T) => void;
|
|
minChars?: number;
|
|
};
|
|
|
|
// Generic async search-select used by the position lookup (hire wizard),
|
|
// manager/superior lookup (create position), and employee multi-select
|
|
// (reorg workbench) in later phases.
|
|
export function Lookup<T>({ placeholder = "Suchen…", onSearch, renderResult, onSelect, minChars = 2 }: LookupProps<T>) {
|
|
const [query, setQuery] = useState("");
|
|
const [results, setResults] = useState<T[]>([]);
|
|
const [open, setOpen] = useState(false);
|
|
const [loading, setLoading] = useState(false);
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
if (query.trim().length < minChars) {
|
|
setResults([]);
|
|
setOpen(false);
|
|
return;
|
|
}
|
|
let cancelled = false;
|
|
setLoading(true);
|
|
const timeout = setTimeout(() => {
|
|
onSearch(query.trim()).then((res) => {
|
|
if (cancelled) return;
|
|
setResults(res);
|
|
setOpen(true);
|
|
setLoading(false);
|
|
});
|
|
}, 200);
|
|
return () => {
|
|
cancelled = true;
|
|
clearTimeout(timeout);
|
|
};
|
|
}, [query, minChars, onSearch]);
|
|
|
|
useEffect(() => {
|
|
function onClickOutside(e: MouseEvent) {
|
|
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
|
setOpen(false);
|
|
}
|
|
}
|
|
document.addEventListener("mousedown", onClickOutside);
|
|
return () => document.removeEventListener("mousedown", onClickOutside);
|
|
}, []);
|
|
|
|
return (
|
|
<div ref={containerRef} className="relative">
|
|
<div className="flex items-center gap-2 rounded border border-border bg-white px-3 py-2">
|
|
<Search className="h-4 w-4 shrink-0 text-ink-muted" />
|
|
<input
|
|
value={query}
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
placeholder={placeholder}
|
|
className="w-full text-sm outline-none placeholder:text-ink-muted"
|
|
/>
|
|
{query && (
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setQuery("");
|
|
setResults([]);
|
|
setOpen(false);
|
|
}}
|
|
aria-label="Zurücksetzen"
|
|
>
|
|
<X className="h-4 w-4 text-ink-muted" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
{open && (
|
|
<div className="absolute z-20 mt-1 max-h-64 w-full overflow-y-auto rounded border border-border bg-white shadow-lg">
|
|
{loading && <div className="px-3 py-2 text-sm text-ink-muted">Suche…</div>}
|
|
{!loading && results.length === 0 && <div className="px-3 py-2 text-sm text-ink-muted">Keine Treffer</div>}
|
|
{!loading &&
|
|
results.map((item, i) => (
|
|
<button
|
|
key={i}
|
|
type="button"
|
|
onClick={() => {
|
|
onSelect(item);
|
|
setQuery("");
|
|
setResults([]);
|
|
setOpen(false);
|
|
}}
|
|
className="block w-full px-3 py-2 text-left text-sm hover:bg-surface"
|
|
>
|
|
{renderResult(item)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|