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:
20
components/shell/NewHireButton.tsx
Normal file
20
components/shell/NewHireButton.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { Plus } from "lucide-react";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
|
||||
// Stub for Phase 1: the real 4-step hire wizard (§4.4) ships in a later phase.
|
||||
export function NewHireButton() {
|
||||
const { showToast } = useToast();
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => showToast("Der Neueinstellungs-Assistent folgt in einer späteren Ausbaustufe.", "info")}
|
||||
className="flex items-center gap-1.5 rounded bg-brand-500 px-3 py-1.5 text-sm font-semibold text-white hover:bg-brand-600"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Neueinstellung
|
||||
</button>
|
||||
);
|
||||
}
|
||||
44
components/shell/Sidebar.tsx
Normal file
44
components/shell/Sidebar.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import { BarChart3, Building2, History, LayoutGrid, Network, Users } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ href: "/", label: "Übersicht", icon: LayoutGrid },
|
||||
{ href: "/employees", label: "Mitarbeiter:innen", icon: Users },
|
||||
{ href: "/orgchart", label: "Organigramm", icon: Network },
|
||||
{ href: "/positions", label: "Positionen & Bereiche", icon: Building2 },
|
||||
{ href: "/reports", label: "Berichte", icon: BarChart3 },
|
||||
{ href: "/audit", label: "Audit-Log", icon: History },
|
||||
] as const;
|
||||
|
||||
export function Sidebar() {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<nav className="fixed left-0 top-0 flex h-full w-[236px] flex-col border-r border-border bg-white">
|
||||
<div className="flex h-14 items-center border-b border-border px-5">
|
||||
<span className="text-base font-extrabold text-brand-700">Alpenwerk HR</span>
|
||||
</div>
|
||||
<ul className="flex-1 space-y-1 px-3 py-4">
|
||||
{NAV_ITEMS.map(({ href, label, icon: Icon }) => {
|
||||
const active = href === "/" ? pathname === "/" : pathname.startsWith(href);
|
||||
return (
|
||||
<li key={href}>
|
||||
<Link
|
||||
href={href}
|
||||
className={`flex items-center gap-3 rounded px-3 py-2 text-sm font-semibold transition-colors ${
|
||||
active ? "bg-brand-100 text-brand-700" : "text-ink-body hover:bg-surface"
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-4 w-4 shrink-0" />
|
||||
{label}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
48
components/shell/Topbar.tsx
Normal file
48
components/shell/Topbar.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { LogOut } from "lucide-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { logout } from "@/actions/auth";
|
||||
import { NewHireButton } from "./NewHireButton";
|
||||
|
||||
const TITLES: Record<string, string> = {
|
||||
"/": "Übersicht",
|
||||
"/employees": "Mitarbeiter:innen",
|
||||
"/orgchart": "Organigramm",
|
||||
"/positions": "Positionen & Bereiche",
|
||||
"/reports": "Berichte",
|
||||
"/audit": "Audit-Log",
|
||||
};
|
||||
|
||||
function titleFor(pathname: string): string {
|
||||
if (TITLES[pathname]) return TITLES[pathname];
|
||||
const match = Object.keys(TITLES).find((key) => key !== "/" && pathname.startsWith(key));
|
||||
return match ? TITLES[match] : "Alpenwerk HR";
|
||||
}
|
||||
|
||||
type TopbarProps = {
|
||||
userLabel: string;
|
||||
role?: string;
|
||||
};
|
||||
|
||||
export function Topbar({ userLabel, role }: TopbarProps) {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<header className="flex h-14 items-center justify-between border-b border-border bg-white px-6">
|
||||
<h1 className="text-base font-bold text-ink">{titleFor(pathname)}</h1>
|
||||
<div className="flex items-center gap-4">
|
||||
<NewHireButton />
|
||||
<div className="flex items-center gap-2 border-l border-border pl-4 text-sm">
|
||||
<span className="font-semibold text-ink">{userLabel}</span>
|
||||
{role && <span className="text-xs text-ink-muted">({role === "hr_admin" ? "HR-Admin" : "Manager"})</span>}
|
||||
<form action={logout}>
|
||||
<button type="submit" aria-label="Abmelden" className="ml-2 rounded p-1.5 text-ink-muted hover:bg-surface">
|
||||
<LogOut className="h-4 w-4" />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
27
components/ui/Avatar.tsx
Normal file
27
components/ui/Avatar.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { avatarColorFor } from "@/lib/colors";
|
||||
import { initials } from "@/lib/format";
|
||||
|
||||
type AvatarProps = {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
color?: string | null;
|
||||
size?: "sm" | "md" | "lg";
|
||||
};
|
||||
|
||||
const SIZE_CLASSES: Record<NonNullable<AvatarProps["size"]>, string> = {
|
||||
sm: "h-7 w-7 text-xs",
|
||||
md: "h-9 w-9 text-sm",
|
||||
lg: "h-14 w-14 text-lg",
|
||||
};
|
||||
|
||||
export function Avatar({ firstName, lastName, color, size = "md" }: AvatarProps) {
|
||||
const bg = color ?? avatarColorFor(`${firstName}${lastName}`);
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex shrink-0 items-center justify-center rounded-full font-bold text-white ${SIZE_CLASSES[size]}`}
|
||||
style={{ backgroundColor: bg }}
|
||||
>
|
||||
{initials(firstName, lastName)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
46
components/ui/Modal.tsx
Normal file
46
components/ui/Modal.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import { X } from "lucide-react";
|
||||
import { useEffect, type ReactNode } from "react";
|
||||
|
||||
type ModalProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
footer?: ReactNode;
|
||||
widthClassName?: string;
|
||||
};
|
||||
|
||||
export function Modal({ open, onClose, title, children, footer, widthClassName = "max-w-lg" }: ModalProps) {
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
|
||||
<div className={`flex max-h-[85vh] w-full flex-col rounded bg-white shadow-xl ${widthClassName}`}>
|
||||
<div className="flex items-center justify-between border-b border-border px-6 py-4">
|
||||
<h2 className="text-lg font-bold text-ink">{title}</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Schließen"
|
||||
className="rounded p-1 text-ink-muted hover:bg-surface"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4">{children}</div>
|
||||
{footer && <div className="flex items-center justify-end gap-2 border-t border-border px-6 py-4">{footer}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
28
components/ui/SegmentedControl.tsx
Normal file
28
components/ui/SegmentedControl.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
type Option<T extends string> = { value: T; label: string };
|
||||
|
||||
type SegmentedControlProps<T extends string> = {
|
||||
value: T;
|
||||
onChange: (value: T) => void;
|
||||
options: Option<T>[];
|
||||
};
|
||||
|
||||
export function SegmentedControl<T extends string>({ value, onChange, options }: SegmentedControlProps<T>) {
|
||||
return (
|
||||
<div className="inline-flex rounded bg-surface p-1">
|
||||
{options.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => onChange(opt.value)}
|
||||
className={`rounded px-3 py-1.5 text-sm font-semibold transition-colors ${
|
||||
value === opt.value ? "bg-white text-brand-700 shadow-sm" : "text-ink-muted hover:text-ink"
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
55
components/ui/SlideOver.tsx
Normal file
55
components/ui/SlideOver.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import { X } from "lucide-react";
|
||||
import { useEffect, type ReactNode } from "react";
|
||||
|
||||
type SlideOverProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
children: ReactNode;
|
||||
footer?: ReactNode;
|
||||
};
|
||||
|
||||
export function SlideOver({ open, onClose, title, subtitle, children, footer }: SlideOverProps) {
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [open, onClose]);
|
||||
|
||||
return (
|
||||
<div className={`fixed inset-0 z-50 ${open ? "pointer-events-auto" : "pointer-events-none"}`} aria-hidden={!open}>
|
||||
<div
|
||||
className={`absolute inset-0 bg-black/40 transition-opacity ${open ? "opacity-100" : "opacity-0"}`}
|
||||
onClick={onClose}
|
||||
/>
|
||||
<div
|
||||
className={`absolute right-0 top-0 flex h-full w-full max-w-md flex-col bg-white shadow-xl transition-transform duration-200 ${
|
||||
open ? "translate-x-0" : "translate-x-full"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between border-b border-border px-6 py-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-ink">{title}</h2>
|
||||
{subtitle && <p className="text-sm text-ink-muted">{subtitle}</p>}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Schließen"
|
||||
className="rounded p-1 text-ink-muted hover:bg-surface"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4">{children}</div>
|
||||
{footer && <div className="flex items-center justify-end gap-2 border-t border-border px-6 py-4">{footer}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
19
components/ui/StatusChip.tsx
Normal file
19
components/ui/StatusChip.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { STATUS_STYLES } from "@/lib/colors";
|
||||
import { fmtDate } from "@/lib/format";
|
||||
import type { EmploymentStatus } from "@/lib/supabase/types";
|
||||
|
||||
type StatusChipProps = {
|
||||
status: EmploymentStatus;
|
||||
entryDate?: string | null; // shown as "Eintritt {date}" when status is Geplant
|
||||
};
|
||||
|
||||
export function StatusChip({ status, entryDate }: StatusChipProps) {
|
||||
const label = status === "Geplant" && entryDate ? `Eintritt ${fmtDate(entryDate)}` : status;
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center whitespace-nowrap rounded-full px-2.5 py-0.5 text-xs font-semibold ${STATUS_STYLES[status]}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
48
components/ui/Toast.tsx
Normal file
48
components/ui/Toast.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useCallback, useContext, useState, type ReactNode } from "react";
|
||||
|
||||
type ToastVariant = "success" | "error" | "info";
|
||||
type ToastItem = { id: number; message: string; variant: ToastVariant };
|
||||
type ToastContextValue = { showToast: (message: string, variant?: ToastVariant) => void };
|
||||
|
||||
const ToastContext = createContext<ToastContextValue | null>(null);
|
||||
|
||||
const VARIANT_STYLES: Record<ToastVariant, string> = {
|
||||
success: "bg-success-text",
|
||||
error: "bg-danger-solid",
|
||||
info: "bg-info-text",
|
||||
};
|
||||
|
||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const [toasts, setToasts] = useState<ToastItem[]>([]);
|
||||
|
||||
const showToast = useCallback((message: string, variant: ToastVariant = "success") => {
|
||||
const id = Date.now() + Math.random();
|
||||
setToasts((prev) => [...prev, { id, message, variant }]);
|
||||
setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== id)), 4000);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ showToast }}>
|
||||
{children}
|
||||
<div className="pointer-events-none fixed bottom-4 right-4 z-[100] flex flex-col gap-2">
|
||||
{toasts.map((t) => (
|
||||
<div
|
||||
key={t.id}
|
||||
role="status"
|
||||
className={`pointer-events-auto rounded px-4 py-2 text-sm font-semibold text-white shadow-lg ${VARIANT_STYLES[t.variant]}`}
|
||||
>
|
||||
{t.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useToast(): ToastContextValue {
|
||||
const ctx = useContext(ToastContext);
|
||||
if (!ctx) throw new Error("useToast must be used within a ToastProvider");
|
||||
return ctx;
|
||||
}
|
||||
Reference in New Issue
Block a user