diff --git a/actions/employees.ts b/actions/employees.ts index 1076fac..1d98087 100644 --- a/actions/employees.ts +++ b/actions/employees.ts @@ -109,3 +109,23 @@ export async function changeEmployeeData(payload: { export async function rehireEmployee(payload: { employee_id: string; rehire_date: string }): Promise { return callRpc("rehire_employee", payload, [`/employees/${payload.employee_id}`, "/employees", "/"]); } + +export type EmployeeSearchResult = { id: string; first_name: string; last_name: string; job_title: string; team_id: string | null }; + +// Shared by "Intern besetzen" (staff an open position) and the reorg +// workbench's "Mitarbeiter:in(nen)" multi-select — both search active/ +// on-leave employees by name or title. +export async function searchActiveEmployees(query: string): Promise { + const supabase = await createClient(); + let q = supabase + .from("employees_directory") + .select("id, first_name, last_name, job_title, team_id") + .in("status", ["Aktiv", "Karenz"]) + .limit(20); + if (query.trim()) { + const term = query.trim(); + q = q.or(`first_name.ilike.%${term}%,last_name.ilike.%${term}%,job_title.ilike.%${term}%`); + } + const { data } = await q; + return data ?? []; +} diff --git a/actions/positions.ts b/actions/positions.ts index 7267801..044564b 100644 --- a/actions/positions.ts +++ b/actions/positions.ts @@ -34,3 +34,24 @@ export async function staffPositionInternally(payload: { revalidatePath("/"); return { success: true }; } + +export type SuperiorSearchResult = { id: string; first_name: string; last_name: string; job_title: string; division_id: string }; + +// For "Position ausschreiben": superior lookup, filtered to team-leads when +// the new position is an IC role, or to division-heads/CEO when the new +// position is itself a team lead (§2). +export async function searchSuperiors(query: string, forLeadPosition: boolean): Promise { + const supabase = await createClient(); + let q = supabase + .from("employees_directory") + .select("id, first_name, last_name, job_title, division_id") + .eq("status", "Aktiv") + .limit(20); + q = forLeadPosition ? q.lte("org_level", 1) : q.eq("is_lead", true).eq("org_level", 2); + if (query.trim()) { + const term = query.trim(); + q = q.or(`first_name.ilike.%${term}%,last_name.ilike.%${term}%,job_title.ilike.%${term}%`); + } + const { data } = await q; + return data ?? []; +} diff --git a/app/(app)/positions/page.tsx b/app/(app)/positions/page.tsx new file mode 100644 index 0000000..e2a23c4 --- /dev/null +++ b/app/(app)/positions/page.tsx @@ -0,0 +1,73 @@ +import { PositionsPageClient } from "@/components/positions/PositionsPageClient"; +import { daysBetween } from "@/lib/format"; +import { loadOpenPositions } from "@/lib/positions"; +import { createClient } from "@/lib/supabase/server"; + +export default async function PositionsPage() { + const supabase = await createClient(); + + const [openPositions, { data: divisions }, { data: departments }, { data: teams }, { data: activeEmployees }, { data: leads }] = + await Promise.all([ + loadOpenPositions(supabase), + supabase.from("divisions").select("*").order("name"), + supabase.from("departments").select("*"), + supabase.from("teams").select("*"), + supabase.from("employees_directory").select("team_id, division_id, weekly_hours").in("status", ["Aktiv", "Karenz"]), + supabase + .from("employees_directory") + .select("id, first_name, last_name, team_id, division_id, org_level, is_lead") + .eq("status", "Aktiv") + .or("is_lead.eq.true,org_level.eq.0"), + ]); + + const teamStats = new Map(); + const divisionHeadcount = new Map(); + for (const e of activeEmployees ?? []) { + if (e.team_id) { + const s = teamStats.get(e.team_id) ?? { headcount: 0, fte: 0 }; + s.headcount += 1; + s.fte += Number(e.weekly_hours) / 38.5; + teamStats.set(e.team_id, s); + } + if (e.division_id) { + divisionHeadcount.set(e.division_id, (divisionHeadcount.get(e.division_id) ?? 0) + 1); + } + } + + const divisionHeadByDivision = new Map(); + const teamLeadByTeam = new Map(); + for (const p of leads ?? []) { + const name = `${p.first_name} ${p.last_name}`; + if (p.org_level === 1 && p.division_id) divisionHeadByDivision.set(p.division_id, { id: p.id, name }); + if (p.is_lead && p.team_id) teamLeadByTeam.set(p.team_id, { id: p.id, name }); + } + + const openPositionCountByTeam = new Map(); + for (const pos of openPositions) { + openPositionCountByTeam.set(pos.team_id, (openPositionCountByTeam.get(pos.team_id) ?? 0) + 1); + } + + const divisionCards = (divisions ?? []).map((div) => ({ + ...div, + head: divisionHeadByDivision.get(div.id) ?? null, + headcount: divisionHeadcount.get(div.id) ?? 0, + departments: (departments ?? []) + .filter((d) => d.division_id === div.id) + .map((dept) => ({ + ...dept, + teams: (teams ?? []) + .filter((t) => t.department_id === dept.id) + .map((t) => ({ + ...t, + lead: teamLeadByTeam.get(t.id) ?? null, + headcount: teamStats.get(t.id)?.headcount ?? 0, + fte: teamStats.get(t.id)?.fte ?? 0, + openCount: openPositionCountByTeam.get(t.id) ?? 0, + })), + })), + })); + + const openPositionsWithDays = openPositions.map((p) => ({ ...p, daysOpen: daysBetween(p.created_at) })); + + return ; +} diff --git a/components/dashboard/DraftsCard.tsx b/components/dashboard/DraftsCard.tsx index 7c8b3f9..b5d2227 100644 --- a/components/dashboard/DraftsCard.tsx +++ b/components/dashboard/DraftsCard.tsx @@ -45,7 +45,7 @@ export function DraftsCard({ drafts }: { drafts: Draft[] }) { Gespeichert am {fmtDate(d.updated_at)}
- + + + } + > +
+
+ + setTitle(e.target.value)} className="w-full rounded border border-border px-3 py-2 text-sm" /> +
+ +
+ + {!superior ? ( + + placeholder="Name oder Titel…" + onSearch={(q) => searchSuperiors(q, isLead)} + onSelect={setSuperior} + renderResult={(p) => ( +
+
+ {p.first_name} {p.last_name} +
+
{p.job_title}
+
+ )} + /> + ) : ( +
+
+
+ {superior.first_name} {superior.last_name} +
+
{superior.job_title}
+
+ +
+ )} +
+ {isLead && ( +
+ + +
+ )} +
+ + ); +} diff --git a/components/positions/PositionsPageClient.tsx b/components/positions/PositionsPageClient.tsx new file mode 100644 index 0000000..2dd681b --- /dev/null +++ b/components/positions/PositionsPageClient.tsx @@ -0,0 +1,162 @@ +"use client"; + +import { Plus } from "lucide-react"; +import Link from "next/link"; +import { useState } from "react"; +import { useHireWizard } from "@/components/hire/HireWizardContext"; +import type { OpenPositionResolved } from "@/lib/positions"; +import { CreatePositionModal } from "./CreatePositionModal"; +import { StaffInternallyModal } from "./StaffInternallyModal"; + +type Team = { + id: string; + org_number: string; + name: string; + lead: { id: string; name: string } | null; + headcount: number; + fte: number; + openCount: number; +}; +type Department = { id: string; org_number: string; name: string; teams: Team[] }; +type DivisionCard = { + id: string; + org_number: string; + name: string; + head: { id: string; name: string } | null; + headcount: number; + departments: Department[]; +}; +type OpenPositionWithDays = OpenPositionResolved & { daysOpen: number }; + +type PositionsPageClientProps = { + openPositions: OpenPositionWithDays[]; + divisionCards: DivisionCard[]; + teams: { id: string; org_number: string; name: string; department_id: string }[]; +}; + +export function PositionsPageClient({ openPositions, divisionCards, teams }: PositionsPageClientProps) { + const { openWizard } = useHireWizard(); + const [createOpen, setCreateOpen] = useState(false); + const [staffTarget, setStaffTarget] = useState<{ id: string; title: string; is_lead: boolean } | null>(null); + + return ( +
+
+
+

Offene Positionen ({openPositions.length})

+ +
+ {openPositions.length === 0 ? ( +

Derzeit keine offenen Positionen.

+ ) : ( +
+ {openPositions.map((p) => ( +
+
{p.title}
+
+ {p.position_number} · {p.orgLabel} +
+
seit {p.daysOpen} Tagen offen
+
+ + +
+
+ ))} +
+ )} +
+ + {divisionCards.map((div) => ( +
+
+
+
{div.org_number}
+
{div.name}
+ {div.head && ( + + {div.head.name} + + )} +
+
{div.headcount} Mitarbeiter:innen
+
+
+ {div.departments.map((dept) => ( +
+
+ {dept.org_number} · {dept.name} +
+
+ + + + + + + + + + + + {dept.teams.map((team) => ( + + + + + + + + ))} + +
TeamTeamleitungHeadcountFTEOffen
+
{team.name}
+
{team.org_number}
+
+ {team.lead ? ( + + {team.lead.name} + + ) : ( + "–" + )} + {team.headcount}{team.fte.toFixed(1)}{team.openCount || "–"}
+
+
+ ))} +
+
+ ))} + + setCreateOpen(false)} teams={teams} /> + {staffTarget && ( + setStaffTarget(null)} + positionId={staffTarget.id} + positionTitle={staffTarget.title} + isLead={staffTarget.is_lead} + /> + )} +
+ ); +} diff --git a/components/positions/StaffInternallyModal.tsx b/components/positions/StaffInternallyModal.tsx new file mode 100644 index 0000000..b815b41 --- /dev/null +++ b/components/positions/StaffInternallyModal.tsx @@ -0,0 +1,102 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { searchActiveEmployees, type EmployeeSearchResult } from "@/actions/employees"; +import { staffPositionInternally } from "@/actions/positions"; +import { Lookup } from "@/components/ui/Lookup"; +import { Modal } from "@/components/ui/Modal"; +import { useToast } from "@/components/ui/Toast"; + +type StaffInternallyModalProps = { + open: boolean; + onClose: () => void; + positionId: string; + positionTitle: string; + isLead: boolean; +}; + +export function StaffInternallyModal({ open, onClose, positionId, positionTitle, isLead }: StaffInternallyModalProps) { + const { showToast } = useToast(); + const router = useRouter(); + const [employee, setEmployee] = useState(null); + const [pending, setPending] = useState(false); + + async function handleSubmit() { + if (!employee) return; + setPending(true); + const result = await staffPositionInternally({ position_id: positionId, employee_id: employee.id }); + setPending(false); + if (result.success) { + showToast(`${employee.first_name} ${employee.last_name} wurde intern besetzt.`); + router.refresh(); + onClose(); + setEmployee(null); + } else { + showToast(result.error ?? "Fehler beim Besetzen.", "error"); + } + } + + return ( + + + + + } + > +
+

+ Position: {positionTitle} +

+ {isLead && ( +
+ Dies ist eine Führungsposition: Das gesamte Team wird der ausgewählten Person unterstellt. +
+ )} +
+ + {!employee ? ( + + placeholder="Name oder Titel…" + onSearch={searchActiveEmployees} + onSelect={setEmployee} + renderResult={(e) => ( +
+
+ {e.first_name} {e.last_name} +
+
{e.job_title}
+
+ )} + /> + ) : ( +
+
+
+ {employee.first_name} {employee.last_name} +
+
{employee.job_title}
+
+ +
+ )} +
+
+
+ ); +}