Phase 1: project foundation for Alpenwerk HR
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.
This commit is contained in:
104
components/ui/Lookup.tsx
Normal file
104
components/ui/Lookup.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user