Add facilities, contracts, orders, value lists, audit log, and desktop app modules
Extends omsorgCore with full CRUD for Facility/Contract/Order plus configurable value lists and an audit trail, and wires the omsorgapp frontend up to the new facilities, settings, and audit-log modules; includes a sidebar active-nav-item highlight. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
ee74ed65f5
commit
e9e96a57dc
@@ -1,5 +1,7 @@
|
||||
import { createContext, useContext, useEffect, useState, useCallback } from "react";
|
||||
|
||||
const DEFAULT_PASSWORD_MIN_LENGTH = 8;
|
||||
|
||||
const AuthContext = createContext(null);
|
||||
|
||||
export function AuthProvider({ children }) {
|
||||
@@ -7,6 +9,7 @@ export function AuthProvider({ children }) {
|
||||
const [user, setUser] = useState(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [mustChangePassword, setMustChangePassword] = useState(false);
|
||||
const [passwordMinLength, setPasswordMinLength] = useState(DEFAULT_PASSWORD_MIN_LENGTH);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -26,6 +29,14 @@ export function AuthProvider({ children }) {
|
||||
setIsLoading(false);
|
||||
});
|
||||
|
||||
// Kein Login nötig - wird auch vor der Anmeldung (Passwort-vergessen-Flow) gebraucht,
|
||||
// schlägt bei fehlendem Server einfach auf DEFAULT_PASSWORD_MIN_LENGTH zurück.
|
||||
window.omsorg.auth.getPasswordPolicy().then((result) => {
|
||||
if (!cancelled && result.success) {
|
||||
setPasswordMinLength(result.minLength);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unsubscribe();
|
||||
@@ -68,7 +79,17 @@ export function AuthProvider({ children }) {
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{ isAuthenticated, user, isLoading, mustChangePassword, login, logout, changePassword, hasPermission }}
|
||||
value={{
|
||||
isAuthenticated,
|
||||
user,
|
||||
isLoading,
|
||||
mustChangePassword,
|
||||
passwordMinLength,
|
||||
login,
|
||||
logout,
|
||||
changePassword,
|
||||
hasPermission
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
|
||||
@@ -2,12 +2,15 @@ import { useEffect, useState } from "react";
|
||||
import AppLayout from "../layouts/AppLayout";
|
||||
import HomePage from "../modules/home/HomePage";
|
||||
import EmployeesPage from "../modules/employees/EmployeesPage";
|
||||
import FacilitiesPage from "../modules/facilities/FacilitiesPage";
|
||||
import LoginPage from "../modules/auth/LoginPage";
|
||||
import ChangePasswordScreen from "../modules/auth/ChangePasswordScreen";
|
||||
import ForgotPasswordUsernamePage from "../modules/auth/ForgotPasswordUsernamePage";
|
||||
import ForgotPasswordPinPage from "../modules/auth/ForgotPasswordPinPage";
|
||||
import ForgotPasswordNewPasswordPage from "../modules/auth/ForgotPasswordNewPasswordPage";
|
||||
import DebugSessionsPage from "../modules/debug/DebugSessionsPage";
|
||||
import SettingsPage from "../modules/settings/SettingsPage";
|
||||
import AuditLogPage from "../modules/auditLog/AuditLogPage";
|
||||
import { useAuth } from "./AuthContext";
|
||||
import { isNavItemVisible } from "./navPermissions";
|
||||
function PlaceholderPage({ title }) {
|
||||
@@ -76,7 +79,7 @@ export default function App() {
|
||||
case "Mitarbeiter":
|
||||
return <EmployeesPage />;
|
||||
case "Kunden":
|
||||
return <PlaceholderPage title="Kunden" />;
|
||||
return <FacilitiesPage />;
|
||||
case "Disposition":
|
||||
return <PlaceholderPage title="Disposition" />;
|
||||
case "Kalkulation":
|
||||
@@ -88,9 +91,11 @@ export default function App() {
|
||||
case "Controlling":
|
||||
return <PlaceholderPage title="Controlling" />;
|
||||
case "Einstellungen":
|
||||
return <PlaceholderPage title="Einstellungen" />;
|
||||
return <SettingsPage />;
|
||||
case "Debug":
|
||||
return <DebugSessionsPage />;
|
||||
case "Audit-Log":
|
||||
return <AuditLogPage />;
|
||||
default:
|
||||
return <HomePage />;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { ModuleType } from "omsorgcore-client-ts";
|
||||
|
||||
export const NAV_MODULES = {
|
||||
Mitarbeiter: "Employees",
|
||||
Kunden: "Facilities",
|
||||
Disposition: "Orders",
|
||||
Rechnungen: "Invoices",
|
||||
Controlling: "Controlling",
|
||||
Einstellungen: "UserManagement",
|
||||
Debug: "UserManagement"
|
||||
Mitarbeiter: ModuleType.Employees,
|
||||
Kunden: ModuleType.Facilities,
|
||||
Disposition: ModuleType.Orders,
|
||||
Rechnungen: ModuleType.Invoices,
|
||||
Controlling: ModuleType.Controlling,
|
||||
Einstellungen: ModuleType.UserManagement,
|
||||
Debug: ModuleType.UserManagement,
|
||||
"Audit-Log": ModuleType.AuditLog
|
||||
};
|
||||
|
||||
export function isNavItemVisible(label, hasPermission) {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
// Lädt die Items einer konfigurierbaren Auswahlliste (siehe omsorgCore/CLAUDE.md,
|
||||
// Abschnitt "Konfigurierbare Auswahllisten") über window.omsorg.valueLists.listItems.
|
||||
// Ersetzt die früher hier hartcodierten Options-Arrays (Mitarbeiterstatus,
|
||||
// Beschäftigungsart, CRM-Status, Einrichtungstyp, ...).
|
||||
export function useValueListItems(key) {
|
||||
const [items, setItems] = useState([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function load() {
|
||||
setIsLoading(true);
|
||||
const result = await window.omsorg.valueLists.listItems(key);
|
||||
if (!cancelled) {
|
||||
setItems(result.ok ? result.data ?? [] : []);
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
load();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [key]);
|
||||
|
||||
return { items, isLoading };
|
||||
}
|
||||
@@ -9,7 +9,8 @@ import {
|
||||
FileText,
|
||||
BarChart3,
|
||||
Settings,
|
||||
Bug
|
||||
Bug,
|
||||
ScrollText
|
||||
} from "lucide-react";
|
||||
import { useAuth } from "../app/AuthContext";
|
||||
import { isNavItemVisible } from "../app/navPermissions";
|
||||
@@ -24,7 +25,8 @@ const menu = [
|
||||
{ icon: FileText, label: "Rechnungen" },
|
||||
{ icon: BarChart3, label: "Controlling" },
|
||||
{ icon: Settings, label: "Einstellungen" },
|
||||
{ icon: Bug, label: "Debug" }
|
||||
{ icon: Bug, label: "Debug" },
|
||||
{ icon: ScrollText, label: "Audit-Log" }
|
||||
];
|
||||
|
||||
export default function Sidebar({
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
export default function ModalPortal({ children }) {
|
||||
return createPortal(children, document.body);
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { Bell, BriefcaseBusiness, Calculator, CalendarClock, Database, FileText, Home, Mail, Phone, Plus, Save, Settings, Shield, Users } from 'lucide-react';
|
||||
import logo from './assets/omsorg_logo.png';
|
||||
import './style.css';
|
||||
|
||||
const nav = [
|
||||
['home', Home, 'Home'], ['employees', Users, 'Mitarbeiter'], ['customers', BriefcaseBusiness, 'Kunden'],
|
||||
['dispo', CalendarClock, 'Disposition'], ['calc', Calculator, 'Kalkulation'], ['docs', FileText, 'Dokumente'], ['settings', Settings, 'Einstellungen']
|
||||
];
|
||||
const genId = (prefix) => `${prefix}-${Math.random().toString(36).slice(2, 9)}`;
|
||||
|
||||
function App(){
|
||||
const [active,setActive]=useState('home');
|
||||
const [data,setData]=useState(null);
|
||||
const [paths,setPaths]=useState(null);
|
||||
const [selected,setSelected]=useState(null);
|
||||
const [toast,setToast]=useState('');
|
||||
useEffect(()=>{ (async()=>{ setData(await window.omsorg.getDb()); setPaths(await window.omsorg.paths()); })(); },[]);
|
||||
const save = async (next, msg='Gespeichert') => { setData(next); await window.omsorg.setDb(next); setToast(msg); setTimeout(()=>setToast(''),2200); };
|
||||
const warnings = useMemo(()=>{
|
||||
if(!data) return [];
|
||||
const now = new Date();
|
||||
return data.assignments.filter(a=>a.endDate).map(a=>({...a, days: Math.ceil((new Date(a.endDate)-now)/86400000)})).filter(a=>a.days>=0 && a.days<=30);
|
||||
},[data]);
|
||||
if(!data) return <div className="loading">Omsorg Business Controls Pro startet...</div>;
|
||||
return <div className="app">
|
||||
<aside className="sidebar">
|
||||
<div className="brand"><img src={logo}/><div><b>Business Controls Pro</b><span>Release 0.1.1 Foundation</span></div></div>
|
||||
<nav>{nav.map(([id,Icon,label])=><button key={id} onClick={()=>setActive(id)} className={active===id?'active':''}><Icon size={18}/>{label}</button>)}</nav>
|
||||
<div className="sidecard"><Shield size={18}/><div><b>Lokaler Modus</b><span>Daten bleiben auf diesem MacBook.</span></div></div>
|
||||
</aside>
|
||||
<main>
|
||||
<header><div><h1>{nav.find(n=>n[0]===active)?.[2]}</h1><p>Project Aurora · Omsorg Dautovic & Neumann GbR</p></div><div className="header-actions"><button className="bell" onClick={()=>setActive('home')}><Bell size={18}/>{warnings.length>0 && <span>{warnings.length}</span>}</button><button onClick={async()=>{const file=await window.omsorg.createBackup(); setToast('Backup erstellt: '+file)}}>Backup erstellen</button></div></header>
|
||||
{active==='home' && <HomeView data={data} warnings={warnings} setActive={setActive} setSelected={setSelected}/>}
|
||||
{active==='employees' && <Employees data={data} save={save} setSelected={setSelected}/>}
|
||||
{active==='customers' && <Customers data={data} save={save} setSelected={setSelected}/>}
|
||||
{active==='dispo' && <Disposition data={data} save={save} setSelected={setSelected}/>}
|
||||
{active==='calc' && <Calc/>}
|
||||
{active==='docs' && <Docs paths={paths}/>}
|
||||
{active==='settings' && <SettingsView paths={paths}/>}
|
||||
</main>
|
||||
{selected && <Modal item={selected} onClose={()=>setSelected(null)} data={data} save={save}/>}
|
||||
{toast && <div className="toast">{toast}</div>}
|
||||
</div>
|
||||
}
|
||||
function HomeView({data,warnings,setActive,setSelected}){return <section className="grid"><div className="hero card"><h2>Guten Morgen, Omsorg</h2><p>Die erste echte lokale Projektbasis läuft. Ohne native SQLite-Bindings, stabil auf dem MacBook.</p><div className="chips"><span>{data.employees.length} Mitarbeiter</span><span>{data.customers.length} Kunden</span><span>{data.assignments.length} Einsätze</span></div></div><div className="card"><h3>Heute zu erledigen</h3>{warnings.length?warnings.map(w=><button className="warning" key={w.id} onClick={()=>setSelected({type:'assignment',...w})}>⚠️ {w.customerName} endet in {w.days} Tagen</button>):<p className="muted">Keine Vertragswarnungen in den nächsten 30 Tagen.</p>}</div><div className="card wide"><h3>Schnellstart</h3><div className="quick"><button onClick={()=>setActive('employees')}>Mitarbeiter verwalten</button><button onClick={()=>setActive('customers')}>Kunden anlegen</button><button onClick={()=>setActive('dispo')}>Einsatz planen</button><button onClick={()=>setActive('calc')}>Kalkulation öffnen</button></div></div></section>}
|
||||
function Employees({data,save,setSelected}){const [form,setForm]=useState({name:'',qualification:'3-jährige Pflegefachkraft',status:'Aktiv'}); const add=()=>{if(!form.name.trim())return; save({...data,employees:[...data.employees,{id:genId('e'),...form}]}); setForm({...form,name:''});}; const del=(id)=>{if(confirm('Mitarbeiter wirklich löschen?')) save({...data,employees:data.employees.filter(e=>e.id!==id)});}; return <section><div className="form card"><input placeholder="Name" value={form.name} onChange={e=>setForm({...form,name:e.target.value})}/><select value={form.qualification} onChange={e=>setForm({...form,qualification:e.target.value})}><option>3-jährige Pflegefachkraft</option><option>1-jährige Pflegekraft</option><option>Disposition</option><option>Geschäftsführung</option></select><button onClick={add}><Plus size={16}/> Anlegen</button></div><div className="list">{data.employees.map(e=><div className="row" key={e.id} onClick={()=>setSelected({type:'employee',...e})}><div><b>{e.name}</b><span>{e.qualification} · {e.status}</span></div><button onClick={(ev)=>{ev.stopPropagation();del(e.id)}}>Löschen</button></div>)}</div></section>}
|
||||
function Customers({data,save,setSelected}){const [f,setF]=useState({name:'',phone:'',email:'',contact:''}); const add=()=>{if(!f.name.trim())return; save({...data,customers:[...data.customers,{id:genId('c'),...f}]}); setF({name:'',phone:'',email:'',contact:''});}; return <section><div className="form card"><input placeholder="Einrichtung" value={f.name} onChange={e=>setF({...f,name:e.target.value})}/><input placeholder="Telefon" value={f.phone} onChange={e=>setF({...f,phone:e.target.value})}/><input placeholder="E-Mail" value={f.email} onChange={e=>setF({...f,email:e.target.value})}/><button onClick={add}><Plus size={16}/> Kunde anlegen</button></div><div className="list">{data.customers.map(c=><div className="row" key={c.id} onClick={()=>setSelected({type:'customer',...c})}><div><b>{c.name}</b><span>{c.phone || 'keine Telefonnummer'} · {c.email || 'keine E-Mail'}</span></div></div>)}</div></section>}
|
||||
function Disposition({data,save,setSelected}){const [f,setF]=useState({customerName:'',employeeName:'',startDate:'',endDate:'',phone:'',email:''}); const add=()=>{if(!f.customerName.trim())return; save({...data,assignments:[...data.assignments,{id:genId('a'),...f,createdAt:new Date().toISOString()}]}); setF({customerName:'',employeeName:'',startDate:'',endDate:'',phone:'',email:''});}; return <section><div className="form card"><input placeholder="Einrichtung" value={f.customerName} onChange={e=>setF({...f,customerName:e.target.value})}/><input placeholder="Mitarbeiter" value={f.employeeName} onChange={e=>setF({...f,employeeName:e.target.value})}/><input type="date" value={f.startDate} onChange={e=>setF({...f,startDate:e.target.value})}/><input type="date" value={f.endDate} onChange={e=>setF({...f,endDate:e.target.value})}/><input placeholder="Telefon" value={f.phone} onChange={e=>setF({...f,phone:e.target.value})}/><input placeholder="E-Mail" value={f.email} onChange={e=>setF({...f,email:e.target.value})}/><button onClick={add}><Save size={16}/> Einsatz speichern</button></div><div className="list">{data.assignments.map(a=><div className="row" key={a.id} onClick={()=>setSelected({type:'assignment',...a})}><div><b>{a.customerName}</b><span>{a.employeeName || 'ohne Mitarbeiter'} · {a.startDate || '?'} bis {a.endDate || '?'}</span></div></div>)}</div></section>}
|
||||
function Calc(){const [rate,setRate]=useState(48.95),[hours,setHours]=useState(36.5),[margin,setMargin]=useState(25); const revenue=rate*hours; const targetCost=revenue*(1-margin/100); return <section className="grid"><div className="card"><h3>Wochenkalkulation</h3><label>Stundensatz <input type="number" value={rate} onChange={e=>setRate(+e.target.value)}/></label><label>Stunden <input type="number" value={hours} onChange={e=>setHours(+e.target.value)}/></label><label>Zielmarge % <input type="number" value={margin} onChange={e=>setMargin(+e.target.value)}/></label></div><div className="card"><h3>Ergebnis</h3><div className="big">{revenue.toLocaleString('de-DE',{style:'currency',currency:'EUR'})}</div><p>Maximale Kosten bei Zielmarge: <b>{targetCost.toLocaleString('de-DE',{style:'currency',currency:'EUR'})}</b></p></div></section>}
|
||||
function Docs({paths}){return <section className="card"><h3>Dokumentenspeicher</h3><p>Release 0.1.1 legt die Ordnerstruktur lokal im Benutzerordner an.</p><code>{paths?.root}</code><br/><button onClick={()=>window.omsorg.openRoot()}>Ordner im Finder öffnen</button></section>}
|
||||
function SettingsView({paths}){return <section className="card"><h3>Systempfade</h3><p>Datenbank:</p><code>{paths?.db}</code><p>Projektordner:</p><code>{paths?.root}</code></section>}
|
||||
function Modal({item,onClose,data,save}){ const prolong=(id)=>{const newDate=prompt('Neues Vertragsende YYYY-MM-DD'); if(!newDate)return; const next={...data,assignments:data.assignments.map(a=>a.id===id?{...a,endDate:newDate}:a)}; save(next,'Vertrag verlängert'); onClose();}; return <div className="overlay"><div className="modal"><button className="close" onClick={onClose}>×</button><h2>{item.name || item.customerName}</h2>{item.type==='assignment'&&<><p><b>Mitarbeiter:</b> {item.employeeName || '-'}</p><p><b>Vertrag:</b> {item.startDate || '?'} bis {item.endDate || '?'}</p><p><Phone size={16}/> <a href={`tel:${item.phone}`}>{item.phone || 'keine Nummer'}</a></p><p><Mail size={16}/> <a href={`mailto:${item.email}`}>{item.email || 'keine E-Mail'}</a></p><div className="quick"><button onClick={()=>prolong(item.id)}>Vertrag verlängern</button><a className="button" href={`mailto:${item.email}?subject=Vertragsverlängerung`}>E-Mail senden</a></div></>}{item.type==='employee'&&<p>{item.qualification} · {item.status}</p>}{item.type==='customer'&&<><p><Phone size={16}/> <a href={`tel:${item.phone}`}>{item.phone}</a></p><p><Mail size={16}/> <a href={`mailto:${item.email}`}>{item.email}</a></p></>}</div></div>}
|
||||
|
||||
createRoot(document.getElementById('root')).render(<App/>);
|
||||
@@ -0,0 +1,194 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { SlidersHorizontal } from "lucide-react";
|
||||
import OmsorgCard from "../../components/OmsorgCard";
|
||||
import OmsorgButton from "../../components/ui/OmsorgButton";
|
||||
import OmsorgPagination from "../../components/ui/OmsorgPagination";
|
||||
import { useAuth } from "../../app/AuthContext";
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
const ENTITY_TYPE_OPTIONS = [
|
||||
{ value: "Employee", label: "Mitarbeiter" },
|
||||
{ value: "Facility", label: "Einrichtung" },
|
||||
{ value: "Contract", label: "Vertrag" },
|
||||
{ value: "Order", label: "Auftrag" },
|
||||
{ value: "User", label: "Benutzer" },
|
||||
{ value: "Role", label: "Rolle" },
|
||||
];
|
||||
|
||||
const CATEGORY_OPTIONS = [
|
||||
{ value: "EntityChange", label: "Datenänderung" },
|
||||
{ value: "BehavioralEvent", label: "Verhaltens-Ereignis" },
|
||||
];
|
||||
|
||||
function formatDate(iso) {
|
||||
return new Date(iso).toLocaleString("de-DE");
|
||||
}
|
||||
|
||||
function categoryLabel(category) {
|
||||
return CATEGORY_OPTIONS.find((option) => option.value === category)?.label ?? category;
|
||||
}
|
||||
|
||||
// Rein lesend: Audit-Einträge sind unveränderlich, es gibt bewusst keine
|
||||
// Bearbeiten-/Löschen-Aktionen (siehe omsorgCore/CLAUDE.md, Abschnitt "Audit-Log").
|
||||
export default function AuditLogPage() {
|
||||
const { hasPermission } = useAuth();
|
||||
const canViewActor = hasPermission("UserManagement", "View");
|
||||
|
||||
const [entries, setEntries] = useState([]);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const [isFilterOpen, setIsFilterOpen] = useState(false);
|
||||
const [entityTypeFilter, setEntityTypeFilter] = useState("");
|
||||
const [categoryFilter, setCategoryFilter] = useState("");
|
||||
const [actorFilter, setActorFilter] = useState("");
|
||||
const [fromDate, setFromDate] = useState("");
|
||||
const [toDate, setToDate] = useState("");
|
||||
const [users, setUsers] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [entityTypeFilter, categoryFilter, actorFilter, fromDate, toDate]);
|
||||
|
||||
const loadUsers = useCallback(async () => {
|
||||
if (!canViewActor) return;
|
||||
const result = await window.omsorg.users.list();
|
||||
if (result.ok) {
|
||||
setUsers(result.data ?? []);
|
||||
}
|
||||
}, [canViewActor]);
|
||||
|
||||
useEffect(() => {
|
||||
loadUsers();
|
||||
}, [loadUsers]);
|
||||
|
||||
const loadEntries = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const result = await window.omsorg.auditLog.list({
|
||||
page,
|
||||
pageSize: PAGE_SIZE,
|
||||
entityType: entityTypeFilter || undefined,
|
||||
category: categoryFilter || undefined,
|
||||
actorUserId: actorFilter || undefined,
|
||||
fromUtc: fromDate || undefined,
|
||||
toUtc: toDate ? `${toDate}T23:59:59.999` : undefined,
|
||||
});
|
||||
if (result.ok) {
|
||||
const data = result.data ?? { items: [], totalCount: 0 };
|
||||
setEntries(data.items ?? []);
|
||||
setTotalCount(data.totalCount ?? 0);
|
||||
} else {
|
||||
setError("Audit-Log konnte nicht geladen werden.");
|
||||
}
|
||||
setIsLoading(false);
|
||||
}, [page, entityTypeFilter, categoryFilter, actorFilter, fromDate, toDate]);
|
||||
|
||||
useEffect(() => {
|
||||
loadEntries();
|
||||
}, [loadEntries]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE));
|
||||
|
||||
return (
|
||||
<OmsorgCard title="Audit-Log">
|
||||
<p>
|
||||
Nachvollziehbarkeit, wer wann was am System verändert hat - Logins, Datenänderungen
|
||||
und administrative Aktionen. Einträge sind unveränderlich.
|
||||
</p>
|
||||
|
||||
<div className="employees-toolbar">
|
||||
<OmsorgButton
|
||||
variant="secondary"
|
||||
icon={SlidersHorizontal}
|
||||
onClick={() => setIsFilterOpen((open) => !open)}
|
||||
>
|
||||
Filter
|
||||
</OmsorgButton>
|
||||
</div>
|
||||
|
||||
{isFilterOpen && (
|
||||
<div className="employees-filter-panel">
|
||||
<label className="form-field">
|
||||
<span>Entitätstyp</span>
|
||||
<select value={entityTypeFilter} onChange={(event) => setEntityTypeFilter(event.target.value)}>
|
||||
<option value="">Alle</option>
|
||||
{ENTITY_TYPE_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="form-field">
|
||||
<span>Kategorie</span>
|
||||
<select value={categoryFilter} onChange={(event) => setCategoryFilter(event.target.value)}>
|
||||
<option value="">Alle</option>
|
||||
{CATEGORY_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="form-field">
|
||||
<span>Von</span>
|
||||
<input type="date" value={fromDate} onChange={(event) => setFromDate(event.target.value)} />
|
||||
</label>
|
||||
|
||||
<label className="form-field">
|
||||
<span>Bis</span>
|
||||
<input type="date" value={toDate} onChange={(event) => setToDate(event.target.value)} />
|
||||
</label>
|
||||
|
||||
{canViewActor && (
|
||||
<label className="form-field">
|
||||
<span>Akteur</span>
|
||||
<select value={actorFilter} onChange={(event) => setActorFilter(event.target.value)}>
|
||||
<option value="">Alle</option>
|
||||
{users.map((user) => (
|
||||
<option key={user.id} value={user.id}>
|
||||
{user.username}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading && <p>Lädt...</p>}
|
||||
{error && <p className="login-error">{error}</p>}
|
||||
|
||||
{!isLoading && !error && entries.length === 0 && <p>Keine Einträge.</p>}
|
||||
|
||||
{entries.length > 0 && (
|
||||
<ul className="debug-sessions-list">
|
||||
{entries.map((entry) => (
|
||||
<li key={entry.id} className="debug-sessions-row">
|
||||
<div>
|
||||
<strong>{entry.action}</strong>
|
||||
<p className="debug-sessions-meta">
|
||||
{formatDate(entry.occurredAtUtc)} · {entry.actorUsername ?? "System"}
|
||||
{entry.ipAddress ? ` · ${entry.ipAddress}` : ""}
|
||||
{entry.entityType ? ` · ${entry.entityType} (${entry.entityId})` : ""}
|
||||
{` · ${categoryLabel(entry.category)}`}
|
||||
</p>
|
||||
{entry.details && <p className="audit-log-details">{entry.details}</p>}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{entries.length > 0 && (
|
||||
<OmsorgPagination page={page} totalPages={totalPages} onPageChange={setPage} />
|
||||
)}
|
||||
</OmsorgCard>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import OmsorgButton from "../../components/ui/OmsorgButton";
|
||||
import { useAuth } from "../../app/AuthContext";
|
||||
|
||||
export default function ChangePasswordScreen() {
|
||||
const { changePassword } = useAuth();
|
||||
const { changePassword, passwordMinLength } = useAuth();
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
@@ -16,8 +16,8 @@ export default function ChangePasswordScreen() {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
|
||||
if (newPassword.length < 8) {
|
||||
setError("Das neue Passwort muss mindestens 8 Zeichen lang sein.");
|
||||
if (newPassword.length < passwordMinLength) {
|
||||
setError(`Das neue Passwort muss mindestens ${passwordMinLength} Zeichen lang sein.`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -31,11 +31,13 @@ export default function ChangePasswordScreen() {
|
||||
setIsSubmitting(false);
|
||||
|
||||
if (!result.success) {
|
||||
setError(
|
||||
result.error === "invalid_current_password"
|
||||
? "Aktuelles Passwort ist falsch."
|
||||
: "Passwort konnte nicht geändert werden."
|
||||
);
|
||||
if (result.error === "invalid_current_password") {
|
||||
setError("Aktuelles Passwort ist falsch.");
|
||||
} else if (result.error === "password_too_short") {
|
||||
setError(result.message || `Das neue Passwort muss mindestens ${passwordMinLength} Zeichen lang sein.`);
|
||||
} else {
|
||||
setError("Passwort konnte nicht geändert werden.");
|
||||
}
|
||||
}
|
||||
// Bei Erfolg beendet changePassword() die Session lokal - der Login-Screen erscheint automatisch.
|
||||
}
|
||||
|
||||
@@ -2,8 +2,10 @@ import { useState } from "react";
|
||||
import { KeyRound } from "lucide-react";
|
||||
import OmsorgCard from "../../components/OmsorgCard";
|
||||
import OmsorgButton from "../../components/ui/OmsorgButton";
|
||||
import { useAuth } from "../../app/AuthContext";
|
||||
|
||||
export default function ForgotPasswordNewPasswordPage({ resetToken, onDone, onBackToLogin }) {
|
||||
const { passwordMinLength } = useAuth();
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [error, setError] = useState(null);
|
||||
@@ -13,6 +15,11 @@ export default function ForgotPasswordNewPasswordPage({ resetToken, onDone, onBa
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
|
||||
if (password.length < passwordMinLength) {
|
||||
setError(`Das neue Passwort muss mindestens ${passwordMinLength} Zeichen lang sein.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError("Die Passwörter stimmen nicht überein.");
|
||||
return;
|
||||
@@ -24,6 +31,8 @@ export default function ForgotPasswordNewPasswordPage({ resetToken, onDone, onBa
|
||||
|
||||
if (result.success) {
|
||||
onDone();
|
||||
} else if (result.error === "password_too_short") {
|
||||
setError(result.message || `Das neue Passwort muss mindestens ${passwordMinLength} Zeichen lang sein.`);
|
||||
} else {
|
||||
setError("Der Code ist abgelaufen. Bitte fordere einen neuen an.");
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import EmployeeForm, { emptyEmployeeForm, employeeFormToPayload, FormActions } from "./EmployeeForm";
|
||||
import ModalPortal from "../../components/ui/ModalPortal";
|
||||
|
||||
function errorMessage(result) {
|
||||
if (result.status === 403) {
|
||||
@@ -41,18 +42,20 @@ export default function CreateEmployeeDialog({ onClose, onCreated }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" role="dialog" aria-modal="true" aria-label="Neuer Mitarbeiter">
|
||||
<div className="modal-panel">
|
||||
<h2>Neuer Mitarbeiter</h2>
|
||||
<ModalPortal>
|
||||
<div className="modal-overlay" role="dialog" aria-modal="true" aria-label="Neuer Mitarbeiter">
|
||||
<div className="modal-panel">
|
||||
<h2>Neuer Mitarbeiter</h2>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<EmployeeForm form={form} onChange={setForm} />
|
||||
<form onSubmit={handleSubmit}>
|
||||
<EmployeeForm form={form} onChange={setForm} />
|
||||
|
||||
{error && <p className="login-error form-error">{error}</p>}
|
||||
{error && <p className="login-error form-error">{error}</p>}
|
||||
|
||||
<FormActions onCancel={onClose} isSaving={isSaving} submitLabel="Speichern" />
|
||||
</form>
|
||||
<FormActions onCancel={onClose} isSaving={isSaving} submitLabel="Speichern" />
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModalPortal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { FormActions } from "./EmployeeForm";
|
||||
import { useAuth } from "../../app/AuthContext";
|
||||
import ModalPortal from "../../components/ui/ModalPortal";
|
||||
|
||||
const PIN_VALIDITY_OPTIONS = [1, 3, 7, 14];
|
||||
const DEFAULT_PIN_VALIDITY_DAYS = 7;
|
||||
@@ -28,6 +30,7 @@ function errorMessage(result) {
|
||||
}
|
||||
|
||||
export default function CreateUserAccountDialog({ employee, onClose, onCreated }) {
|
||||
const { passwordMinLength } = useAuth();
|
||||
const [username, setUsername] = useState(() => suggestUsername(employee));
|
||||
const [roles, setRoles] = useState([]);
|
||||
const [roleId, setRoleId] = useState("");
|
||||
@@ -39,7 +42,7 @@ export default function CreateUserAccountDialog({ employee, onClose, onCreated }
|
||||
|
||||
useEffect(() => {
|
||||
async function loadRoles() {
|
||||
const result = await window.omsorg.api.get("/api/roles");
|
||||
const result = await window.omsorg.roles.list();
|
||||
if (result.ok) {
|
||||
const data = result.data ?? [];
|
||||
setRoles(data);
|
||||
@@ -64,8 +67,8 @@ export default function CreateUserAccountDialog({ employee, onClose, onCreated }
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === "Direct" && initialPassword.trim().length < 8) {
|
||||
setError("Initiales Passwort muss mindestens 8 Zeichen lang sein.");
|
||||
if (mode === "Direct" && initialPassword.trim().length < passwordMinLength) {
|
||||
setError(`Initiales Passwort muss mindestens ${passwordMinLength} Zeichen lang sein.`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -92,91 +95,93 @@ export default function CreateUserAccountDialog({ employee, onClose, onCreated }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" role="dialog" aria-modal="true" aria-label="Account anlegen">
|
||||
<div className="modal-panel">
|
||||
<h2>Account für {employee.firstName} {employee.lastName} anlegen</h2>
|
||||
<ModalPortal>
|
||||
<div className="modal-overlay" role="dialog" aria-modal="true" aria-label="Account anlegen">
|
||||
<div className="modal-panel">
|
||||
<h2>Account für {employee.firstName} {employee.lastName} anlegen</h2>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<label className="form-field">
|
||||
<span>Username</span>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="form-field">
|
||||
<span>Rolle</span>
|
||||
<select value={roleId} onChange={(event) => setRoleId(event.target.value)}>
|
||||
{roles.map((role) => (
|
||||
<option key={role.id} value={role.id}>
|
||||
{role.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<fieldset className="form-field">
|
||||
<legend>Zugang</legend>
|
||||
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="mode"
|
||||
value="Invite"
|
||||
checked={mode === "Invite"}
|
||||
onChange={() => setMode("Invite")}
|
||||
/>
|
||||
Einladung per Mail (Mitarbeiter setzt eigenes Passwort)
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="mode"
|
||||
value="Direct"
|
||||
checked={mode === "Direct"}
|
||||
onChange={() => setMode("Direct")}
|
||||
/>
|
||||
Passwort direkt vergeben (muss beim ersten Login geändert werden)
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
{mode === "Invite" && (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<label className="form-field">
|
||||
<span>PIN gültig für</span>
|
||||
<select
|
||||
value={pinValidityDays}
|
||||
onChange={(event) => setPinValidityDays(Number(event.target.value))}
|
||||
>
|
||||
{PIN_VALIDITY_OPTIONS.map((days) => (
|
||||
<option key={days} value={days}>
|
||||
{days} {days === 1 ? "Tag" : "Tage"}
|
||||
{days === DEFAULT_PIN_VALIDITY_DAYS ? " (Standard)" : ""}
|
||||
<span>Username</span>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="form-field">
|
||||
<span>Rolle</span>
|
||||
<select value={roleId} onChange={(event) => setRoleId(event.target.value)}>
|
||||
{roles.map((role) => (
|
||||
<option key={role.id} value={role.id}>
|
||||
{role.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{mode === "Direct" && (
|
||||
<label className="form-field">
|
||||
<span>Initiales Passwort</span>
|
||||
<input
|
||||
type="text"
|
||||
value={initialPassword}
|
||||
onChange={(event) => setInitialPassword(event.target.value)}
|
||||
placeholder="Mindestens 8 Zeichen"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<fieldset className="form-field">
|
||||
<legend>Zugang</legend>
|
||||
|
||||
{error && <p className="login-error form-error">{error}</p>}
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="mode"
|
||||
value="Invite"
|
||||
checked={mode === "Invite"}
|
||||
onChange={() => setMode("Invite")}
|
||||
/>
|
||||
Einladung per Mail (Mitarbeiter setzt eigenes Passwort)
|
||||
</label>
|
||||
|
||||
<FormActions onCancel={onClose} isSaving={isSaving} submitLabel="Anlegen" />
|
||||
</form>
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="mode"
|
||||
value="Direct"
|
||||
checked={mode === "Direct"}
|
||||
onChange={() => setMode("Direct")}
|
||||
/>
|
||||
Passwort direkt vergeben (muss beim ersten Login geändert werden)
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
{mode === "Invite" && (
|
||||
<label className="form-field">
|
||||
<span>PIN gültig für</span>
|
||||
<select
|
||||
value={pinValidityDays}
|
||||
onChange={(event) => setPinValidityDays(Number(event.target.value))}
|
||||
>
|
||||
{PIN_VALIDITY_OPTIONS.map((days) => (
|
||||
<option key={days} value={days}>
|
||||
{days} {days === 1 ? "Tag" : "Tage"}
|
||||
{days === DEFAULT_PIN_VALIDITY_DAYS ? " (Standard)" : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{mode === "Direct" && (
|
||||
<label className="form-field">
|
||||
<span>Initiales Passwort</span>
|
||||
<input
|
||||
type="text"
|
||||
value={initialPassword}
|
||||
onChange={(event) => setInitialPassword(event.target.value)}
|
||||
placeholder={`Mindestens ${passwordMinLength} Zeichen`}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{error && <p className="login-error form-error">{error}</p>}
|
||||
|
||||
<FormActions onCancel={onClose} isSaving={isSaving} submitLabel="Anlegen" />
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModalPortal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import EmployeeForm, { employeeFormToPayload, employeeToFormValues, FormActions } from "./EmployeeForm";
|
||||
import ModalPortal from "../../components/ui/ModalPortal";
|
||||
|
||||
function errorMessage(result) {
|
||||
if (result.status === 403) {
|
||||
@@ -42,18 +43,20 @@ export default function EditEmployeeDialog({ employee, onClose, onUpdated }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" role="dialog" aria-modal="true" aria-label="Mitarbeiter bearbeiten">
|
||||
<div className="modal-panel">
|
||||
<h2>{`${employee.firstName} ${employee.lastName}`.trim()} bearbeiten</h2>
|
||||
<ModalPortal>
|
||||
<div className="modal-overlay" role="dialog" aria-modal="true" aria-label="Mitarbeiter bearbeiten">
|
||||
<div className="modal-panel">
|
||||
<h2>{`${employee.firstName} ${employee.lastName}`.trim()} bearbeiten</h2>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<EmployeeForm form={form} onChange={setForm} includeStatus />
|
||||
<form onSubmit={handleSubmit}>
|
||||
<EmployeeForm form={form} onChange={setForm} includeStatus />
|
||||
|
||||
{error && <p className="login-error form-error">{error}</p>}
|
||||
{error && <p className="login-error form-error">{error}</p>}
|
||||
|
||||
<FormActions onCancel={onClose} isSaving={isSaving} submitLabel="Speichern" />
|
||||
</form>
|
||||
<FormActions onCancel={onClose} isSaving={isSaving} submitLabel="Speichern" />
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModalPortal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,5 @@
|
||||
import OmsorgButton from "../../components/ui/OmsorgButton";
|
||||
|
||||
export const EMPLOYMENT_TYPES = [
|
||||
"Vollzeit",
|
||||
"Teilzeit",
|
||||
"Minijob",
|
||||
"Aushilfe",
|
||||
"Praktikant",
|
||||
"Freiberuflich",
|
||||
];
|
||||
|
||||
export const STATUS_OPTIONS = ["Aktiv", "Einsatz", "Urlaub", "Krank"];
|
||||
import { useValueListItems } from "../../app/useValueListItems";
|
||||
|
||||
export const emptyEmployeeForm = {
|
||||
firstName: "",
|
||||
@@ -81,6 +71,9 @@ export function employeeFormToPayload(form, { includeStatus = false } = {}) {
|
||||
}
|
||||
|
||||
export default function EmployeeForm({ form, onChange, includeStatus = false }) {
|
||||
const { items: statusOptions } = useValueListItems("EmployeeStatus");
|
||||
const { items: employmentTypes } = useValueListItems("EmploymentType");
|
||||
|
||||
function updateField(field) {
|
||||
return (event) => onChange({ ...form, [field]: event.target.value });
|
||||
}
|
||||
@@ -101,9 +94,9 @@ export default function EmployeeForm({ form, onChange, includeStatus = false })
|
||||
<label className="form-field">
|
||||
<span>Status</span>
|
||||
<select value={form.status} onChange={updateField("status")}>
|
||||
{STATUS_OPTIONS.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
{statusOptions.map((option) => (
|
||||
<option key={option.id} value={option.value}>
|
||||
{option.value}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -165,9 +158,9 @@ export default function EmployeeForm({ form, onChange, includeStatus = false })
|
||||
<span>Beschäftigungsart</span>
|
||||
<select value={form.employmentType} onChange={updateField("employmentType")}>
|
||||
<option value="">— nicht angegeben —</option>
|
||||
{EMPLOYMENT_TYPES.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
{employmentTypes.map((option) => (
|
||||
<option key={option.id} value={option.value}>
|
||||
{option.value}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
@@ -14,7 +14,7 @@ import { useAuth } from "../../app/AuthContext";
|
||||
import EmployeeDetailPanel from "./EmployeeDetailPanel";
|
||||
import CreateEmployeeDialog from "./CreateEmployeeDialog";
|
||||
import CreateUserAccountDialog from "./CreateUserAccountDialog";
|
||||
import { STATUS_OPTIONS, EMPLOYMENT_TYPES } from "./EmployeeForm";
|
||||
import { useValueListItems } from "../../app/useValueListItems";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
@@ -28,6 +28,8 @@ function fullName(employee) {
|
||||
|
||||
export default function EmployeesPage() {
|
||||
const { hasPermission } = useAuth();
|
||||
const { items: statusOptions } = useValueListItems("EmployeeStatus");
|
||||
const { items: employmentTypes } = useValueListItems("EmploymentType");
|
||||
const canCreate = hasPermission("Employees", "Create");
|
||||
const canViewAccounts = hasPermission("UserManagement", "View");
|
||||
const canCreateAccounts = hasPermission("UserManagement", "Create");
|
||||
@@ -187,9 +189,9 @@ export default function EmployeesPage() {
|
||||
<span>Status</span>
|
||||
<select value={statusFilter} onChange={(event) => setStatusFilter(event.target.value)}>
|
||||
<option value="">Alle</option>
|
||||
{STATUS_OPTIONS.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
{statusOptions.map((option) => (
|
||||
<option key={option.id} value={option.value}>
|
||||
{option.value}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -202,9 +204,9 @@ export default function EmployeesPage() {
|
||||
onChange={(event) => setEmploymentTypeFilter(event.target.value)}
|
||||
>
|
||||
<option value="">Alle</option>
|
||||
{EMPLOYMENT_TYPES.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
{employmentTypes.map((option) => (
|
||||
<option key={option.id} value={option.value}>
|
||||
{option.value}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import FacilityContactForm, {
|
||||
emptyFacilityContactForm,
|
||||
facilityContactFormToPayload,
|
||||
FormActions,
|
||||
} from "./FacilityContactForm";
|
||||
import ModalPortal from "../../components/ui/ModalPortal";
|
||||
|
||||
function errorMessage(result) {
|
||||
if (result.status === 403) {
|
||||
return "Keine Berechtigung, Ansprechpartner anzulegen.";
|
||||
}
|
||||
if (result.status === 400) {
|
||||
return typeof result.data === "string" ? result.data : "Eingaben prüfen.";
|
||||
}
|
||||
return "Ansprechpartner konnte nicht angelegt werden.";
|
||||
}
|
||||
|
||||
export default function CreateFacilityContactDialog({ facilityId, onClose, onCreated }) {
|
||||
const [form, setForm] = useState(emptyFacilityContactForm);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
async function handleSubmit(event) {
|
||||
event.preventDefault();
|
||||
|
||||
if (!form.name.trim()) {
|
||||
setError("Name ist erforderlich.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
setError(null);
|
||||
|
||||
const result = await window.omsorg.facilityContacts.create(facilityId, facilityContactFormToPayload(form));
|
||||
|
||||
setIsSaving(false);
|
||||
|
||||
if (!result.ok) {
|
||||
setError(errorMessage(result));
|
||||
return;
|
||||
}
|
||||
|
||||
onCreated(result.data);
|
||||
}
|
||||
|
||||
return (
|
||||
<ModalPortal>
|
||||
<div className="modal-overlay" role="dialog" aria-modal="true" aria-label="Neuer Ansprechpartner">
|
||||
<div className="modal-panel">
|
||||
<h2>Neuer Ansprechpartner</h2>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<FacilityContactForm form={form} onChange={setForm} />
|
||||
|
||||
{error && <p className="login-error form-error">{error}</p>}
|
||||
|
||||
<FormActions onCancel={onClose} isSaving={isSaving} submitLabel="Speichern" />
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</ModalPortal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import FacilityForm, { emptyFacilityForm, facilityFormToPayload, FormActions } from "./FacilityForm";
|
||||
import ModalPortal from "../../components/ui/ModalPortal";
|
||||
|
||||
function errorMessage(result) {
|
||||
if (result.status === 403) {
|
||||
return "Keine Berechtigung, Einrichtungen anzulegen.";
|
||||
}
|
||||
if (result.status === 400) {
|
||||
return typeof result.data === "string" ? result.data : "Eingaben prüfen.";
|
||||
}
|
||||
return "Einrichtung konnte nicht angelegt werden.";
|
||||
}
|
||||
|
||||
export default function CreateFacilityDialog({ onClose, onCreated }) {
|
||||
const [form, setForm] = useState(emptyFacilityForm);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
async function handleSubmit(event) {
|
||||
event.preventDefault();
|
||||
|
||||
if (!form.name.trim()) {
|
||||
setError("Name ist erforderlich.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
setError(null);
|
||||
|
||||
const result = await window.omsorg.facilities.create(facilityFormToPayload(form));
|
||||
|
||||
setIsSaving(false);
|
||||
|
||||
if (!result.ok) {
|
||||
setError(errorMessage(result));
|
||||
return;
|
||||
}
|
||||
|
||||
onCreated(result.data);
|
||||
}
|
||||
|
||||
return (
|
||||
<ModalPortal>
|
||||
<div className="modal-overlay" role="dialog" aria-modal="true" aria-label="Neue Einrichtung">
|
||||
<div className="modal-panel">
|
||||
<h2>Neue Einrichtung</h2>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<FacilityForm form={form} onChange={setForm} />
|
||||
|
||||
{error && <p className="login-error form-error">{error}</p>}
|
||||
|
||||
<FormActions onCancel={onClose} isSaving={isSaving} submitLabel="Speichern" />
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</ModalPortal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import FacilityContactForm, {
|
||||
facilityContactFormToPayload,
|
||||
facilityContactToFormValues,
|
||||
FormActions,
|
||||
} from "./FacilityContactForm";
|
||||
import ModalPortal from "../../components/ui/ModalPortal";
|
||||
|
||||
function errorMessage(result) {
|
||||
if (result.status === 403) {
|
||||
return "Keine Berechtigung, Ansprechpartner zu bearbeiten.";
|
||||
}
|
||||
if (result.status === 400) {
|
||||
return typeof result.data === "string" ? result.data : "Eingaben prüfen.";
|
||||
}
|
||||
return "Ansprechpartner konnte nicht gespeichert werden.";
|
||||
}
|
||||
|
||||
export default function EditFacilityContactDialog({ facilityId, contact, onClose, onUpdated }) {
|
||||
const [form, setForm] = useState(() => facilityContactToFormValues(contact));
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
async function handleSubmit(event) {
|
||||
event.preventDefault();
|
||||
|
||||
if (!form.name.trim()) {
|
||||
setError("Name ist erforderlich.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
setError(null);
|
||||
|
||||
const payload = facilityContactFormToPayload(form);
|
||||
const result = await window.omsorg.facilityContacts.update(facilityId, contact.id, payload);
|
||||
|
||||
setIsSaving(false);
|
||||
|
||||
if (!result.ok) {
|
||||
setError(errorMessage(result));
|
||||
return;
|
||||
}
|
||||
|
||||
onUpdated(result.data);
|
||||
}
|
||||
|
||||
return (
|
||||
<ModalPortal>
|
||||
<div className="modal-overlay" role="dialog" aria-modal="true" aria-label="Ansprechpartner bearbeiten">
|
||||
<div className="modal-panel">
|
||||
<h2>{contact.name} bearbeiten</h2>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<FacilityContactForm form={form} onChange={setForm} />
|
||||
|
||||
{error && <p className="login-error form-error">{error}</p>}
|
||||
|
||||
<FormActions onCancel={onClose} isSaving={isSaving} submitLabel="Speichern" />
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</ModalPortal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import FacilityForm, { facilityFormToPayload, facilityToFormValues, FormActions } from "./FacilityForm";
|
||||
import ModalPortal from "../../components/ui/ModalPortal";
|
||||
|
||||
function errorMessage(result) {
|
||||
if (result.status === 403) {
|
||||
return "Keine Berechtigung, Einrichtungen zu bearbeiten.";
|
||||
}
|
||||
if (result.status === 400) {
|
||||
return typeof result.data === "string" ? result.data : "Eingaben prüfen.";
|
||||
}
|
||||
return "Einrichtung konnte nicht gespeichert werden.";
|
||||
}
|
||||
|
||||
export default function EditFacilityDialog({ facility, onClose, onUpdated }) {
|
||||
const [form, setForm] = useState(() => facilityToFormValues(facility));
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
async function handleSubmit(event) {
|
||||
event.preventDefault();
|
||||
|
||||
if (!form.name.trim()) {
|
||||
setError("Name ist erforderlich.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
setError(null);
|
||||
|
||||
const payload = facilityFormToPayload(form, { includeCrmStatus: true });
|
||||
const result = await window.omsorg.facilities.update(facility.id, payload);
|
||||
|
||||
setIsSaving(false);
|
||||
|
||||
if (!result.ok) {
|
||||
setError(errorMessage(result));
|
||||
return;
|
||||
}
|
||||
|
||||
onUpdated(result.data);
|
||||
}
|
||||
|
||||
return (
|
||||
<ModalPortal>
|
||||
<div className="modal-overlay" role="dialog" aria-modal="true" aria-label="Einrichtung bearbeiten">
|
||||
<div className="modal-panel">
|
||||
<h2>{facility.name} bearbeiten</h2>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<FacilityForm form={form} onChange={setForm} includeCrmStatus />
|
||||
|
||||
{error && <p className="login-error form-error">{error}</p>}
|
||||
|
||||
<FormActions onCancel={onClose} isSaving={isSaving} submitLabel="Speichern" />
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</ModalPortal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Plus, Search, SlidersHorizontal } from "lucide-react";
|
||||
|
||||
import OmsorgCard from "../../components/OmsorgCard";
|
||||
import OmsorgButton from "../../components/ui/OmsorgButton";
|
||||
import OmsorgPagination from "../../components/ui/OmsorgPagination";
|
||||
import { useAuth } from "../../app/AuthContext";
|
||||
import FacilityDetailPanel from "./FacilityDetailPanel";
|
||||
import CreateFacilityDialog from "./CreateFacilityDialog";
|
||||
import { useValueListItems } from "../../app/useValueListItems";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
export default function FacilitiesPage() {
|
||||
const { hasPermission } = useAuth();
|
||||
const canCreate = hasPermission("Facilities", "Create");
|
||||
const { items: crmStatusOptions } = useValueListItems("CrmStatus");
|
||||
|
||||
const [facilities, setFacilities] = useState([]);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [selectedFacilityId, setSelectedFacilityId] = useState(null);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [debouncedSearch, setDebouncedSearch] = useState("");
|
||||
const [crmStatusFilter, setCrmStatusFilter] = useState("");
|
||||
const [isFilterOpen, setIsFilterOpen] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebouncedSearch(searchTerm.trim()), 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchTerm]);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [debouncedSearch, crmStatusFilter]);
|
||||
|
||||
const loadFacilities = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const result = await window.omsorg.facilities.list({
|
||||
search: debouncedSearch || undefined,
|
||||
crmStatus: crmStatusFilter || undefined,
|
||||
page,
|
||||
pageSize: PAGE_SIZE,
|
||||
});
|
||||
|
||||
if (result.ok) {
|
||||
const data = result.data ?? { items: [], totalCount: 0 };
|
||||
setFacilities(data.items ?? []);
|
||||
setTotalCount(data.totalCount ?? 0);
|
||||
setSelectedFacilityId((current) =>
|
||||
(data.items ?? []).some((facility) => facility.id === current)
|
||||
? current
|
||||
: data.items?.[0]?.id ?? null
|
||||
);
|
||||
} else {
|
||||
setError("Einrichtungen konnten nicht geladen werden.");
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
}, [debouncedSearch, crmStatusFilter, page]);
|
||||
|
||||
useEffect(() => {
|
||||
loadFacilities();
|
||||
}, [loadFacilities]);
|
||||
|
||||
const selectedFacility = facilities.find((facility) => facility.id === selectedFacilityId) ?? null;
|
||||
const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE));
|
||||
|
||||
function handleCreated(createdFacility) {
|
||||
setIsDialogOpen(false);
|
||||
loadFacilities();
|
||||
if (createdFacility?.id) {
|
||||
setSelectedFacilityId(createdFacility.id);
|
||||
}
|
||||
}
|
||||
|
||||
function handleUpdated() {
|
||||
loadFacilities();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="employees-page">
|
||||
<div className="page-heading">
|
||||
<div>
|
||||
<p className="page-eyebrow">CRM</p>
|
||||
|
||||
<h1>Kunden</h1>
|
||||
|
||||
<p className="page-description">
|
||||
Verwalte Einrichtungen, Ansprechpartner und den CRM-Status an einem Ort.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{canCreate && (
|
||||
<OmsorgButton icon={Plus} onClick={() => setIsDialogOpen(true)}>
|
||||
Neue Einrichtung
|
||||
</OmsorgButton>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<OmsorgCard>
|
||||
<div className="employees-toolbar">
|
||||
<div className="employees-search">
|
||||
<Search size={18} />
|
||||
|
||||
<input
|
||||
type="search"
|
||||
value={searchTerm}
|
||||
onChange={(event) => setSearchTerm(event.target.value)}
|
||||
placeholder="Einrichtung suchen..."
|
||||
aria-label="Einrichtung suchen"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<OmsorgButton
|
||||
variant="secondary"
|
||||
icon={SlidersHorizontal}
|
||||
onClick={() => setIsFilterOpen((open) => !open)}
|
||||
>
|
||||
Filter
|
||||
</OmsorgButton>
|
||||
</div>
|
||||
|
||||
{isFilterOpen && (
|
||||
<div className="employees-filter-panel">
|
||||
<label className="form-field">
|
||||
<span>CRM-Status</span>
|
||||
<select value={crmStatusFilter} onChange={(event) => setCrmStatusFilter(event.target.value)}>
|
||||
<option value="">Alle</option>
|
||||
{crmStatusOptions.map((option) => (
|
||||
<option key={option.id} value={option.value}>
|
||||
{option.value}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</OmsorgCard>
|
||||
|
||||
{error && <p className="login-error">{error}</p>}
|
||||
{isLoading && <p>Lädt...</p>}
|
||||
|
||||
{!isLoading && (
|
||||
<div className="employees-layout">
|
||||
<div className="employees-list">
|
||||
{facilities.map((facility) => {
|
||||
const isSelected = facility.id === selectedFacilityId;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={facility.id}
|
||||
type="button"
|
||||
className={
|
||||
isSelected
|
||||
? "employee-list-button employee-list-button--active"
|
||||
: "employee-list-button"
|
||||
}
|
||||
onClick={() => setSelectedFacilityId(facility.id)}
|
||||
>
|
||||
<OmsorgCard>
|
||||
<div className="employee-row">
|
||||
<div className="employee-main">
|
||||
<strong>{facility.name}</strong>
|
||||
{facility.facilityType && <span> — {facility.facilityType}</span>}
|
||||
</div>
|
||||
|
||||
<div className="employee-status">
|
||||
<span className="omsorg-badge omsorg-badge--neutral">{facility.crmStatus}</span>
|
||||
</div>
|
||||
</div>
|
||||
</OmsorgCard>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{facilities.length === 0 && (
|
||||
<OmsorgCard>
|
||||
<div className="employees-empty-state">
|
||||
<strong>Keine Einrichtung gefunden</strong>
|
||||
|
||||
<span>Prüfe den eingegebenen Suchbegriff oder die Filter.</span>
|
||||
</div>
|
||||
</OmsorgCard>
|
||||
)}
|
||||
|
||||
{facilities.length > 0 && (
|
||||
<OmsorgPagination page={page} totalPages={totalPages} onPageChange={setPage} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<FacilityDetailPanel facility={selectedFacility} onUpdated={handleUpdated} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isDialogOpen && (
|
||||
<CreateFacilityDialog onClose={() => setIsDialogOpen(false)} onCreated={handleCreated} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import OmsorgButton from "../../components/ui/OmsorgButton";
|
||||
|
||||
export const emptyFacilityContactForm = {
|
||||
name: "",
|
||||
role: "",
|
||||
department: "",
|
||||
phoneNumber: "",
|
||||
email: "",
|
||||
notes: "",
|
||||
};
|
||||
|
||||
export function facilityContactToFormValues(contact) {
|
||||
return {
|
||||
name: contact.name ?? "",
|
||||
role: contact.role ?? "",
|
||||
department: contact.department ?? "",
|
||||
phoneNumber: contact.phoneNumber ?? "",
|
||||
email: contact.email ?? "",
|
||||
notes: contact.notes ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
export function facilityContactFormToPayload(form) {
|
||||
return {
|
||||
name: form.name.trim(),
|
||||
role: form.role.trim() || null,
|
||||
department: form.department.trim() || null,
|
||||
phoneNumber: form.phoneNumber.trim() || null,
|
||||
email: form.email.trim() || null,
|
||||
notes: form.notes.trim() || null,
|
||||
};
|
||||
}
|
||||
|
||||
export default function FacilityContactForm({ form, onChange }) {
|
||||
function updateField(field) {
|
||||
return (event) => onChange({ ...form, [field]: event.target.value });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="form-grid">
|
||||
<label className="form-field">
|
||||
<span>Name *</span>
|
||||
<input value={form.name} onChange={updateField("name")} required />
|
||||
</label>
|
||||
|
||||
<label className="form-field">
|
||||
<span>Funktion</span>
|
||||
<input value={form.role} onChange={updateField("role")} />
|
||||
</label>
|
||||
|
||||
<label className="form-field">
|
||||
<span>Abteilung</span>
|
||||
<input value={form.department} onChange={updateField("department")} />
|
||||
</label>
|
||||
|
||||
<label className="form-field">
|
||||
<span>Telefon</span>
|
||||
<input value={form.phoneNumber} onChange={updateField("phoneNumber")} />
|
||||
</label>
|
||||
|
||||
<label className="form-field">
|
||||
<span>E-Mail</span>
|
||||
<input type="email" value={form.email} onChange={updateField("email")} />
|
||||
</label>
|
||||
|
||||
<label className="form-field">
|
||||
<span>Notizen</span>
|
||||
<input value={form.notes} onChange={updateField("notes")} />
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FormActions({ onCancel, isSaving, submitLabel }) {
|
||||
return (
|
||||
<div className="form-actions">
|
||||
<OmsorgButton variant="secondary" type="button" onClick={onCancel} disabled={isSaving}>
|
||||
Abbrechen
|
||||
</OmsorgButton>
|
||||
<OmsorgButton type="submit" disabled={isSaving}>
|
||||
{isSaving ? "Speichert..." : submitLabel}
|
||||
</OmsorgButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Pencil, Plus } from "lucide-react";
|
||||
|
||||
import OmsorgButton from "../../components/ui/OmsorgButton";
|
||||
import { useAuth } from "../../app/AuthContext";
|
||||
import CreateFacilityContactDialog from "./CreateFacilityContactDialog";
|
||||
import EditFacilityContactDialog from "./EditFacilityContactDialog";
|
||||
|
||||
export default function FacilityContactsList({ facilityId }) {
|
||||
const { hasPermission } = useAuth();
|
||||
const canCreate = hasPermission("Facilities", "Create");
|
||||
const canEdit = hasPermission("Facilities", "Edit");
|
||||
|
||||
const [contacts, setContacts] = useState([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [editingContact, setEditingContact] = useState(null);
|
||||
|
||||
const loadContacts = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const result = await window.omsorg.facilityContacts.list(facilityId);
|
||||
|
||||
if (result.ok) {
|
||||
setContacts(result.data ?? []);
|
||||
} else {
|
||||
setError("Ansprechpartner konnten nicht geladen werden.");
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
}, [facilityId]);
|
||||
|
||||
useEffect(() => {
|
||||
loadContacts();
|
||||
}, [loadContacts]);
|
||||
|
||||
function handleCreated() {
|
||||
setIsDialogOpen(false);
|
||||
loadContacts();
|
||||
}
|
||||
|
||||
function handleUpdated() {
|
||||
setEditingContact(null);
|
||||
loadContacts();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="employee-tab-content">
|
||||
<div className="page-heading">
|
||||
<h3>Ansprechpartner</h3>
|
||||
|
||||
{canCreate && (
|
||||
<OmsorgButton icon={Plus} variant="secondary" onClick={() => setIsDialogOpen(true)}>
|
||||
Neuer Ansprechpartner
|
||||
</OmsorgButton>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <p className="login-error">{error}</p>}
|
||||
{isLoading && <p>Lädt...</p>}
|
||||
|
||||
{!isLoading && contacts.length === 0 && <p>Noch kein Ansprechpartner erfasst.</p>}
|
||||
|
||||
{!isLoading && contacts.length > 0 && (
|
||||
<div className="facility-contacts-list">
|
||||
{contacts.map((contact) => (
|
||||
<div key={contact.id} className="facility-contact-row">
|
||||
<div>
|
||||
<strong>{contact.name}</strong>
|
||||
{contact.role && <span> — {contact.role}</span>}
|
||||
{contact.department && <span> ({contact.department})</span>}
|
||||
<p>
|
||||
{[contact.phoneNumber, contact.email].filter(Boolean).join(" · ") || "—"}
|
||||
</p>
|
||||
{contact.notes && <p>{contact.notes}</p>}
|
||||
</div>
|
||||
|
||||
{canEdit && (
|
||||
<OmsorgButton
|
||||
variant="secondary"
|
||||
icon={Pencil}
|
||||
onClick={() => setEditingContact(contact)}
|
||||
>
|
||||
Bearbeiten
|
||||
</OmsorgButton>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isDialogOpen && (
|
||||
<CreateFacilityContactDialog
|
||||
facilityId={facilityId}
|
||||
onClose={() => setIsDialogOpen(false)}
|
||||
onCreated={handleCreated}
|
||||
/>
|
||||
)}
|
||||
|
||||
{editingContact && (
|
||||
<EditFacilityContactDialog
|
||||
facilityId={facilityId}
|
||||
contact={editingContact}
|
||||
onClose={() => setEditingContact(null)}
|
||||
onUpdated={handleUpdated}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useState } from "react";
|
||||
import { Pencil } from "lucide-react";
|
||||
|
||||
import OmsorgCard from "../../components/OmsorgCard";
|
||||
import OmsorgButton from "../../components/ui/OmsorgButton";
|
||||
import { useAuth } from "../../app/AuthContext";
|
||||
import EditFacilityDialog from "./EditFacilityDialog";
|
||||
import FacilityContactsList from "./FacilityContactsList";
|
||||
|
||||
function formatAddress(street, postalCode, city, country) {
|
||||
const line = [postalCode, city].filter(Boolean).join(" ");
|
||||
const parts = [street, line].filter(Boolean);
|
||||
|
||||
if (country && country !== "Deutschland") {
|
||||
parts.push(country);
|
||||
}
|
||||
|
||||
return parts.length > 0 ? parts.join(", ") : "—";
|
||||
}
|
||||
|
||||
export default function FacilityDetailPanel({ facility, onUpdated }) {
|
||||
const { hasPermission } = useAuth();
|
||||
const canEdit = hasPermission("Facilities", "Edit");
|
||||
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
||||
|
||||
if (!facility) {
|
||||
return (
|
||||
<OmsorgCard>
|
||||
<div className="employee-detail-empty">
|
||||
<h2>Keine Einrichtung ausgewählt</h2>
|
||||
<p>Wähle links eine Einrichtung aus.</p>
|
||||
</div>
|
||||
</OmsorgCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<OmsorgCard>
|
||||
<div className="employee-detail">
|
||||
<div className="employee-detail-header">
|
||||
<div>
|
||||
<h2>{facility.name}</h2>
|
||||
<span className="omsorg-badge omsorg-badge--neutral">{facility.crmStatus}</span>
|
||||
</div>
|
||||
|
||||
{canEdit && (
|
||||
<OmsorgButton variant="secondary" icon={Pencil} onClick={() => setIsEditDialogOpen(true)}>
|
||||
Bearbeiten
|
||||
</OmsorgButton>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="employee-detail-grid">
|
||||
<div>
|
||||
<strong>Art der Einrichtung</strong>
|
||||
<p>{facility.facilityType ?? "—"}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<strong>Website</strong>
|
||||
<p>
|
||||
{facility.website ? (
|
||||
<a href={facility.website} target="_blank" rel="noreferrer">
|
||||
{facility.website}
|
||||
</a>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<strong>Adresse</strong>
|
||||
<p>{formatAddress(facility.street, facility.postalCode, facility.city, facility.country)}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<strong>Rechnungsadresse</strong>
|
||||
<p>
|
||||
{formatAddress(
|
||||
facility.billingStreet,
|
||||
facility.billingPostalCode,
|
||||
facility.billingCity,
|
||||
facility.billingCountry
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FacilityContactsList facilityId={facility.id} />
|
||||
</div>
|
||||
|
||||
{isEditDialogOpen && (
|
||||
<EditFacilityDialog
|
||||
facility={facility}
|
||||
onClose={() => setIsEditDialogOpen(false)}
|
||||
onUpdated={(updatedFacility) => {
|
||||
setIsEditDialogOpen(false);
|
||||
onUpdated?.(updatedFacility);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</OmsorgCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import OmsorgButton from "../../components/ui/OmsorgButton";
|
||||
import { useValueListItems } from "../../app/useValueListItems";
|
||||
|
||||
export const emptyFacilityForm = {
|
||||
name: "",
|
||||
crmStatus: "Lead",
|
||||
facilityType: "",
|
||||
website: "",
|
||||
street: "",
|
||||
postalCode: "",
|
||||
city: "",
|
||||
country: "",
|
||||
billingStreet: "",
|
||||
billingPostalCode: "",
|
||||
billingCity: "",
|
||||
billingCountry: "",
|
||||
};
|
||||
|
||||
export function facilityToFormValues(facility) {
|
||||
return {
|
||||
name: facility.name ?? "",
|
||||
crmStatus: facility.crmStatus ?? "Lead",
|
||||
facilityType: facility.facilityType ?? "",
|
||||
website: facility.website ?? "",
|
||||
street: facility.street ?? "",
|
||||
postalCode: facility.postalCode ?? "",
|
||||
city: facility.city ?? "",
|
||||
country: facility.country ?? "",
|
||||
billingStreet: facility.billingStreet ?? "",
|
||||
billingPostalCode: facility.billingPostalCode ?? "",
|
||||
billingCity: facility.billingCity ?? "",
|
||||
billingCountry: facility.billingCountry ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
export function facilityFormToPayload(form, { includeCrmStatus = false } = {}) {
|
||||
const payload = {
|
||||
name: form.name.trim(),
|
||||
facilityType: form.facilityType || null,
|
||||
website: form.website.trim() || null,
|
||||
street: form.street.trim() || null,
|
||||
postalCode: form.postalCode.trim() || null,
|
||||
city: form.city.trim() || null,
|
||||
country: form.country.trim() || null,
|
||||
billingStreet: form.billingStreet.trim() || null,
|
||||
billingPostalCode: form.billingPostalCode.trim() || null,
|
||||
billingCity: form.billingCity.trim() || null,
|
||||
billingCountry: form.billingCountry.trim() || null,
|
||||
};
|
||||
|
||||
if (includeCrmStatus) {
|
||||
payload.crmStatus = form.crmStatus;
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
export default function FacilityForm({ form, onChange, includeCrmStatus = false }) {
|
||||
const { items: crmStatusOptions } = useValueListItems("CrmStatus");
|
||||
const { items: facilityTypes } = useValueListItems("FacilityType");
|
||||
|
||||
function updateField(field) {
|
||||
return (event) => onChange({ ...form, [field]: event.target.value });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="form-grid">
|
||||
<label className="form-field">
|
||||
<span>Name *</span>
|
||||
<input value={form.name} onChange={updateField("name")} required />
|
||||
</label>
|
||||
|
||||
{includeCrmStatus && (
|
||||
<label className="form-field">
|
||||
<span>CRM-Status</span>
|
||||
<select value={form.crmStatus} onChange={updateField("crmStatus")}>
|
||||
{crmStatusOptions.map((option) => (
|
||||
<option key={option.id} value={option.value}>
|
||||
{option.value}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
|
||||
<label className="form-field">
|
||||
<span>Art der Einrichtung</span>
|
||||
<select value={form.facilityType} onChange={updateField("facilityType")}>
|
||||
<option value="">— nicht angegeben —</option>
|
||||
{facilityTypes.map((option) => (
|
||||
<option key={option.id} value={option.value}>
|
||||
{option.value}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="form-field">
|
||||
<span>Website</span>
|
||||
<input type="url" value={form.website} onChange={updateField("website")} placeholder="https://…" />
|
||||
</label>
|
||||
|
||||
<fieldset className="form-field form-field--fieldset">
|
||||
<legend>Adresse</legend>
|
||||
|
||||
<label className="form-field">
|
||||
<span>Straße und Hausnummer</span>
|
||||
<input value={form.street} onChange={updateField("street")} />
|
||||
</label>
|
||||
|
||||
<div className="form-row">
|
||||
<label className="form-field">
|
||||
<span>PLZ</span>
|
||||
<input value={form.postalCode} onChange={updateField("postalCode")} />
|
||||
</label>
|
||||
|
||||
<label className="form-field">
|
||||
<span>Ort</span>
|
||||
<input value={form.city} onChange={updateField("city")} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="form-field">
|
||||
<span>Land</span>
|
||||
<input value={form.country} onChange={updateField("country")} />
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="form-field form-field--fieldset">
|
||||
<legend>Rechnungsadresse</legend>
|
||||
|
||||
<label className="form-field">
|
||||
<span>Straße und Hausnummer</span>
|
||||
<input value={form.billingStreet} onChange={updateField("billingStreet")} />
|
||||
</label>
|
||||
|
||||
<div className="form-row">
|
||||
<label className="form-field">
|
||||
<span>PLZ</span>
|
||||
<input value={form.billingPostalCode} onChange={updateField("billingPostalCode")} />
|
||||
</label>
|
||||
|
||||
<label className="form-field">
|
||||
<span>Ort</span>
|
||||
<input value={form.billingCity} onChange={updateField("billingCity")} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="form-field">
|
||||
<span>Land</span>
|
||||
<input value={form.billingCountry} onChange={updateField("billingCountry")} />
|
||||
</label>
|
||||
</fieldset>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FormActions({ onCancel, isSaving, submitLabel }) {
|
||||
return (
|
||||
<div className="form-actions">
|
||||
<OmsorgButton variant="secondary" type="button" onClick={onCancel} disabled={isSaving}>
|
||||
Abbrechen
|
||||
</OmsorgButton>
|
||||
<OmsorgButton type="submit" disabled={isSaving}>
|
||||
{isSaving ? "Speichert..." : submitLabel}
|
||||
</OmsorgButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Save } from "lucide-react";
|
||||
import OmsorgButton from "../../components/ui/OmsorgButton";
|
||||
import { MODULE_OPTIONS, ACTION_OPTIONS } from "./permissionOptions";
|
||||
|
||||
function permissionKey(module, action) {
|
||||
return `${module}:${action}`;
|
||||
}
|
||||
|
||||
export default function RolePermissionMatrix({ roleId, roleName, canEdit }) {
|
||||
const [checked, setChecked] = useState(() => new Set());
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [savedMessage, setSavedMessage] = useState(null);
|
||||
|
||||
const loadRole = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
setSavedMessage(null);
|
||||
const result = await window.omsorg.roles.get(roleId);
|
||||
if (result.ok) {
|
||||
setChecked(new Set(result.data.permissions.map((p) => permissionKey(p.module, p.action))));
|
||||
} else {
|
||||
setError("Rechte konnten nicht geladen werden.");
|
||||
}
|
||||
setIsLoading(false);
|
||||
}, [roleId]);
|
||||
|
||||
useEffect(() => {
|
||||
loadRole();
|
||||
}, [loadRole]);
|
||||
|
||||
function toggle(module, action) {
|
||||
if (!canEdit) return;
|
||||
const key = permissionKey(module, action);
|
||||
setChecked((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) {
|
||||
next.delete(key);
|
||||
} else {
|
||||
next.add(key);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
setIsSaving(true);
|
||||
setError(null);
|
||||
setSavedMessage(null);
|
||||
|
||||
const permissions = Array.from(checked).map((key) => {
|
||||
const [module, action] = key.split(":");
|
||||
return { module, action };
|
||||
});
|
||||
|
||||
const result = await window.omsorg.roles.updatePermissions(roleId, { permissions });
|
||||
|
||||
setIsSaving(false);
|
||||
if (!result.ok) {
|
||||
setError("Rechte konnten nicht gespeichert werden.");
|
||||
return;
|
||||
}
|
||||
setSavedMessage("Gespeichert.");
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <p>Lädt...</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="settings-permission-matrix">
|
||||
<h3>Rechte-Matrix: {roleName}</h3>
|
||||
{error && <p className="login-error">{error}</p>}
|
||||
|
||||
<div className="settings-permission-matrix-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
{ACTION_OPTIONS.map((action) => (
|
||||
<th key={action.value}>{action.label}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{MODULE_OPTIONS.map((module) => (
|
||||
<tr key={module.value}>
|
||||
<td>{module.label}</td>
|
||||
{ACTION_OPTIONS.map((action) => (
|
||||
<td key={action.value}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked.has(permissionKey(module.value, action.value))}
|
||||
onChange={() => toggle(module.value, action.value)}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{canEdit && (
|
||||
<div className="settings-permission-matrix-actions">
|
||||
<OmsorgButton icon={Save} onClick={handleSave} disabled={isSaving}>
|
||||
{isSaving ? "Speichert..." : "Speichern"}
|
||||
</OmsorgButton>
|
||||
{savedMessage && <span className="debug-sessions-meta">{savedMessage}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Plus } from "lucide-react";
|
||||
import OmsorgButton from "../../components/ui/OmsorgButton";
|
||||
import RolePermissionMatrix from "./RolePermissionMatrix";
|
||||
import { useAuth } from "../../app/AuthContext";
|
||||
|
||||
export default function RolesPanel() {
|
||||
const { hasPermission } = useAuth();
|
||||
const canCreate = hasPermission("UserManagement", "Create");
|
||||
const canEdit = hasPermission("UserManagement", "Edit");
|
||||
|
||||
const [roles, setRoles] = useState([]);
|
||||
const [selectedRoleId, setSelectedRoleId] = useState(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [newRoleName, setNewRoleName] = useState("");
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [createError, setCreateError] = useState(null);
|
||||
|
||||
const loadRoles = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const result = await window.omsorg.roles.list();
|
||||
if (result.ok) {
|
||||
const data = result.data ?? [];
|
||||
setRoles(data);
|
||||
setSelectedRoleId((current) => current ?? data[0]?.id ?? null);
|
||||
} else {
|
||||
setError("Rollen konnten nicht geladen werden.");
|
||||
}
|
||||
setIsLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadRoles();
|
||||
}, [loadRoles]);
|
||||
|
||||
async function handleCreateRole(event) {
|
||||
event.preventDefault();
|
||||
if (!newRoleName.trim()) {
|
||||
setCreateError("Name ist erforderlich.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCreating(true);
|
||||
setCreateError(null);
|
||||
const result = await window.omsorg.roles.create({ name: newRoleName.trim() });
|
||||
setIsCreating(false);
|
||||
|
||||
if (!result.ok) {
|
||||
setCreateError(result.status === 409 ? "Diese Rolle existiert bereits." : "Rolle konnte nicht angelegt werden.");
|
||||
return;
|
||||
}
|
||||
|
||||
setNewRoleName("");
|
||||
await loadRoles();
|
||||
setSelectedRoleId(result.data.id);
|
||||
}
|
||||
|
||||
const selectedRole = roles.find((role) => role.id === selectedRoleId);
|
||||
|
||||
return (
|
||||
<div className="settings-roles-panel">
|
||||
<div className="settings-panel-heading">
|
||||
<h4>Rollen</h4>
|
||||
|
||||
{canCreate && (
|
||||
<form onSubmit={handleCreateRole} className="settings-new-role-form">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Neue Rolle"
|
||||
value={newRoleName}
|
||||
onChange={(event) => setNewRoleName(event.target.value)}
|
||||
/>
|
||||
<OmsorgButton icon={Plus} type="submit" variant="secondary" disabled={isCreating}>
|
||||
Anlegen
|
||||
</OmsorgButton>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
{createError && <p className="login-error">{createError}</p>}
|
||||
|
||||
{isLoading && <p>Lädt...</p>}
|
||||
{error && <p className="login-error">{error}</p>}
|
||||
|
||||
{!isLoading && !error && (
|
||||
<div className="settings-roles-layout">
|
||||
<div className="settings-roles-list">
|
||||
<ul>
|
||||
{roles.map((role) => (
|
||||
<li key={role.id}>
|
||||
<button
|
||||
type="button"
|
||||
className={role.id === selectedRoleId ? "settings-role-item settings-role-item--active" : "settings-role-item"}
|
||||
onClick={() => setSelectedRoleId(role.id)}
|
||||
>
|
||||
{role.name}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="settings-roles-detail">
|
||||
{selectedRole ? (
|
||||
<RolePermissionMatrix roleId={selectedRole.id} roleName={selectedRole.name} canEdit={canEdit} />
|
||||
) : (
|
||||
<p>Keine Rolle ausgewählt.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useState } from "react";
|
||||
import OmsorgCard from "../../components/OmsorgCard";
|
||||
import RolesPanel from "./RolesPanel";
|
||||
import UserOverridesPanel from "./UserOverridesPanel";
|
||||
import StatusManagementPanel from "./StatusManagementPanel";
|
||||
|
||||
const TABS = [
|
||||
{ id: "roles", label: "Rollen" },
|
||||
{ id: "overrides", label: "Benutzerrechte" },
|
||||
{ id: "statuses", label: "Status-Verwaltung" },
|
||||
];
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [activeTab, setActiveTab] = useState("roles");
|
||||
|
||||
return (
|
||||
<OmsorgCard title="Einstellungen">
|
||||
<div className="settings-tabs">
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
className={tab.id === activeTab ? "settings-tab settings-tab--active" : "settings-tab"}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{activeTab === "roles" && <RolesPanel />}
|
||||
{activeTab === "overrides" && <UserOverridesPanel />}
|
||||
{activeTab === "statuses" && <StatusManagementPanel />}
|
||||
</OmsorgCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
import { Fragment, useCallback, useEffect, useState } from "react";
|
||||
import { Plus, Save, Trash2, Info } from "lucide-react";
|
||||
import OmsorgButton from "../../components/ui/OmsorgButton";
|
||||
import { useAuth } from "../../app/AuthContext";
|
||||
|
||||
const ORDER_STATUS_KEY = "OrderStatus";
|
||||
|
||||
export default function StatusManagementPanel() {
|
||||
const { hasPermission } = useAuth();
|
||||
const canCreate = hasPermission("UserManagement", "Create");
|
||||
const canEdit = hasPermission("UserManagement", "Edit");
|
||||
|
||||
const [lists, setLists] = useState([]);
|
||||
const [selectedKey, setSelectedKey] = useState(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const loadLists = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const result = await window.omsorg.valueLists.list();
|
||||
if (result.ok) {
|
||||
const data = result.data ?? [];
|
||||
setLists(data);
|
||||
setSelectedKey((current) => current ?? data[0]?.key ?? null);
|
||||
} else {
|
||||
setError("Auswahllisten konnten nicht geladen werden.");
|
||||
}
|
||||
setIsLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadLists();
|
||||
}, [loadLists]);
|
||||
|
||||
const selectedList = lists.find((list) => list.key === selectedKey);
|
||||
|
||||
return (
|
||||
<div className="settings-roles-panel">
|
||||
<div className="settings-panel-heading">
|
||||
<h4>Status-Verwaltung</h4>
|
||||
</div>
|
||||
|
||||
{isLoading && <p>Lädt...</p>}
|
||||
{error && <p className="login-error">{error}</p>}
|
||||
|
||||
{!isLoading && !error && (
|
||||
<div className="settings-roles-layout">
|
||||
<div className="settings-roles-list">
|
||||
<ul>
|
||||
{lists.map((list) => (
|
||||
<li key={list.key}>
|
||||
<button
|
||||
type="button"
|
||||
className={
|
||||
list.key === selectedKey ? "settings-role-item settings-role-item--active" : "settings-role-item"
|
||||
}
|
||||
onClick={() => setSelectedKey(list.key)}
|
||||
>
|
||||
{list.displayName}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="settings-roles-detail">
|
||||
{selectedList ? (
|
||||
<ValueListDetail
|
||||
listKey={selectedList.key}
|
||||
displayName={selectedList.displayName}
|
||||
canCreate={canCreate}
|
||||
canEdit={canEdit}
|
||||
/>
|
||||
) : (
|
||||
<p>Keine Liste ausgewählt.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ValueListDetail({ listKey, displayName, canCreate, canEdit }) {
|
||||
const [items, setItems] = useState([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const [newValue, setNewValue] = useState("");
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [createError, setCreateError] = useState(null);
|
||||
|
||||
const [usagesByItemId, setUsagesByItemId] = useState({});
|
||||
const [blockedDeleteId, setBlockedDeleteId] = useState(null);
|
||||
const [blockedUsages, setBlockedUsages] = useState([]);
|
||||
|
||||
const isOrderStatus = listKey === ORDER_STATUS_KEY;
|
||||
|
||||
const loadItems = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
setBlockedDeleteId(null);
|
||||
const result = await window.omsorg.valueLists.listItems(listKey);
|
||||
if (result.ok) {
|
||||
setItems(result.data ?? []);
|
||||
} else {
|
||||
setError("Werte konnten nicht geladen werden.");
|
||||
}
|
||||
setIsLoading(false);
|
||||
}, [listKey]);
|
||||
|
||||
useEffect(() => {
|
||||
loadItems();
|
||||
setUsagesByItemId({});
|
||||
}, [loadItems]);
|
||||
|
||||
async function handleCreate(event) {
|
||||
event.preventDefault();
|
||||
if (!newValue.trim()) {
|
||||
setCreateError("Wert ist erforderlich.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCreating(true);
|
||||
setCreateError(null);
|
||||
const result = await window.omsorg.valueLists.createItem(listKey, {
|
||||
value: newValue.trim(),
|
||||
sortOrder: items.length,
|
||||
isDefault: false,
|
||||
isInitial: false,
|
||||
isTerminal: false,
|
||||
});
|
||||
setIsCreating(false);
|
||||
|
||||
if (!result.ok) {
|
||||
setCreateError(result.status === 400 ? "Ungültiger Wert." : "Wert konnte nicht angelegt werden.");
|
||||
return;
|
||||
}
|
||||
|
||||
setNewValue("");
|
||||
await loadItems();
|
||||
}
|
||||
|
||||
async function handleSetDefault(item) {
|
||||
if (!canEdit) return;
|
||||
await window.omsorg.valueLists.updateItem(listKey, item.id, {
|
||||
value: item.value,
|
||||
sortOrder: item.sortOrder,
|
||||
isDefault: true,
|
||||
isInitial: item.isInitial,
|
||||
isTerminal: item.isTerminal,
|
||||
});
|
||||
await loadItems();
|
||||
}
|
||||
|
||||
async function handleToggleFlag(item, flag) {
|
||||
if (!canEdit) return;
|
||||
await window.omsorg.valueLists.updateItem(listKey, item.id, {
|
||||
value: item.value,
|
||||
sortOrder: item.sortOrder,
|
||||
isDefault: item.isDefault,
|
||||
isInitial: flag === "isInitial" ? !item.isInitial : item.isInitial,
|
||||
isTerminal: flag === "isTerminal" ? !item.isTerminal : item.isTerminal,
|
||||
});
|
||||
await loadItems();
|
||||
}
|
||||
|
||||
async function handleShowUsages(item) {
|
||||
const result = await window.omsorg.valueLists.getUsages(listKey, item.id);
|
||||
setUsagesByItemId((prev) => ({ ...prev, [item.id]: result.ok ? result.data ?? [] : [] }));
|
||||
}
|
||||
|
||||
async function handleDelete(item) {
|
||||
if (!canEdit) return;
|
||||
setBlockedDeleteId(null);
|
||||
|
||||
const result = await window.omsorg.valueLists.deleteItem(listKey, item.id);
|
||||
if (result.ok) {
|
||||
await loadItems();
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.status === 409) {
|
||||
setBlockedDeleteId(item.id);
|
||||
setBlockedUsages(result.data ?? []);
|
||||
return;
|
||||
}
|
||||
|
||||
setError("Wert konnte nicht gelöscht werden.");
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <p>Lädt...</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="settings-permission-matrix">
|
||||
<h3>{displayName}</h3>
|
||||
{error && <p className="login-error">{error}</p>}
|
||||
|
||||
<div className="settings-permission-matrix-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Wert</th>
|
||||
<th>Standard</th>
|
||||
{isOrderStatus && <th>Start</th>}
|
||||
{isOrderStatus && <th>Ende</th>}
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item) => (
|
||||
<Fragment key={item.id}>
|
||||
<tr key={item.id}>
|
||||
<td>{item.value}</td>
|
||||
<td>
|
||||
<input
|
||||
type="radio"
|
||||
name={`${listKey}-default`}
|
||||
checked={item.isDefault}
|
||||
disabled={!canEdit}
|
||||
onChange={() => handleSetDefault(item)}
|
||||
/>
|
||||
</td>
|
||||
{isOrderStatus && (
|
||||
<td>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={item.isInitial}
|
||||
disabled={!canEdit}
|
||||
onChange={() => handleToggleFlag(item, "isInitial")}
|
||||
/>
|
||||
</td>
|
||||
)}
|
||||
{isOrderStatus && (
|
||||
<td>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={item.isTerminal}
|
||||
disabled={!canEdit}
|
||||
onChange={() => handleToggleFlag(item, "isTerminal")}
|
||||
/>
|
||||
</td>
|
||||
)}
|
||||
<td>
|
||||
<OmsorgButton
|
||||
icon={Info}
|
||||
variant="secondary"
|
||||
type="button"
|
||||
onClick={() => handleShowUsages(item)}
|
||||
>
|
||||
Verwendung
|
||||
</OmsorgButton>
|
||||
{canEdit && (
|
||||
<OmsorgButton
|
||||
icon={Trash2}
|
||||
variant="secondary"
|
||||
type="button"
|
||||
onClick={() => handleDelete(item)}
|
||||
>
|
||||
Löschen
|
||||
</OmsorgButton>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
{usagesByItemId[item.id] && (
|
||||
<tr key={`${item.id}-usages`}>
|
||||
<td colSpan={isOrderStatus ? 5 : 3}>
|
||||
{usagesByItemId[item.id].length === 0 ? (
|
||||
<span className="debug-sessions-meta">Wird nirgends verwendet.</span>
|
||||
) : (
|
||||
<span className="debug-sessions-meta">
|
||||
Wird verwendet bei: {usagesByItemId[item.id].map((u) => u.displayLabel).join(", ")}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{blockedDeleteId === item.id && (
|
||||
<tr key={`${item.id}-blocked`}>
|
||||
<td colSpan={isOrderStatus ? 5 : 3}>
|
||||
<p className="login-error">
|
||||
Löschen nicht möglich - wird noch verwendet bei: {blockedUsages.map((u) => u.displayLabel).join(", ")}.
|
||||
Bitte dort zuerst entfernen/ändern.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{canCreate && (
|
||||
<form onSubmit={handleCreate} className="settings-new-role-form">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Neuer Wert"
|
||||
value={newValue}
|
||||
onChange={(event) => setNewValue(event.target.value)}
|
||||
/>
|
||||
<OmsorgButton icon={Plus} type="submit" variant="secondary" disabled={isCreating}>
|
||||
Anlegen
|
||||
</OmsorgButton>
|
||||
</form>
|
||||
)}
|
||||
{createError && <p className="login-error">{createError}</p>}
|
||||
|
||||
{isOrderStatus && <OrderStatusTransitions items={items} canEdit={canEdit} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OrderStatusTransitions({ items, canEdit }) {
|
||||
const [transitions, setTransitions] = useState([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [savedMessage, setSavedMessage] = useState(null);
|
||||
|
||||
const loadTransitions = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
const result = await window.omsorg.valueLists.listTransitions(ORDER_STATUS_KEY);
|
||||
setTransitions(result.ok ? result.data ?? [] : []);
|
||||
setIsLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadTransitions();
|
||||
}, [loadTransitions]);
|
||||
|
||||
function isAllowed(fromId, toId) {
|
||||
return transitions.some((t) => t.fromItemId === fromId && t.toItemId === toId);
|
||||
}
|
||||
|
||||
function toggle(fromId, toId) {
|
||||
if (!canEdit) return;
|
||||
setSavedMessage(null);
|
||||
setTransitions((prev) => {
|
||||
if (prev.some((t) => t.fromItemId === fromId && t.toItemId === toId)) {
|
||||
return prev.filter((t) => !(t.fromItemId === fromId && t.toItemId === toId));
|
||||
}
|
||||
return [...prev, { fromItemId: fromId, toItemId: toId }];
|
||||
});
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
setIsSaving(true);
|
||||
setSavedMessage(null);
|
||||
const result = await window.omsorg.valueLists.replaceTransitions(ORDER_STATUS_KEY, transitions);
|
||||
setIsSaving(false);
|
||||
setSavedMessage(result.ok ? "Gespeichert." : "Speichern fehlgeschlagen.");
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <p>Lädt Übergänge...</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="settings-permission-matrix" style={{ marginTop: "2rem" }}>
|
||||
<h3>Erlaubte Übergänge</h3>
|
||||
<div className="settings-permission-matrix-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Von \ Nach</th>
|
||||
{items.map((to) => (
|
||||
<th key={to.id}>{to.value}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((from) => (
|
||||
<tr key={from.id}>
|
||||
<td>{from.value}</td>
|
||||
{items.map((to) => (
|
||||
<td key={to.id}>
|
||||
{from.id !== to.id && (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isAllowed(from.id, to.id)}
|
||||
disabled={!canEdit}
|
||||
onChange={() => toggle(from.id, to.id)}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{canEdit && (
|
||||
<div className="settings-permission-matrix-actions">
|
||||
<OmsorgButton icon={Save} onClick={handleSave} disabled={isSaving}>
|
||||
{isSaving ? "Speichert..." : "Speichern"}
|
||||
</OmsorgButton>
|
||||
{savedMessage && <span className="debug-sessions-meta">{savedMessage}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import OmsorgButton from "../../components/ui/OmsorgButton";
|
||||
import { useAuth } from "../../app/AuthContext";
|
||||
import { MODULE_OPTIONS, ACTION_OPTIONS, EFFECT_OPTIONS } from "./permissionOptions";
|
||||
|
||||
function labelFor(options, value) {
|
||||
return options.find((option) => option.value === value)?.label ?? value;
|
||||
}
|
||||
|
||||
export default function UserOverridesPanel() {
|
||||
const { hasPermission } = useAuth();
|
||||
const canEdit = hasPermission("UserManagement", "Edit");
|
||||
|
||||
const [users, setUsers] = useState([]);
|
||||
const [selectedUserId, setSelectedUserId] = useState(null);
|
||||
const [overrides, setOverrides] = useState([]);
|
||||
const [isLoadingUsers, setIsLoadingUsers] = useState(true);
|
||||
const [isLoadingOverrides, setIsLoadingOverrides] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const [newModule, setNewModule] = useState(MODULE_OPTIONS[0].value);
|
||||
const [newAction, setNewAction] = useState(ACTION_OPTIONS[0].value);
|
||||
const [newEffect, setNewEffect] = useState(EFFECT_OPTIONS[0].value);
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
const [addError, setAddError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadUsers() {
|
||||
setIsLoadingUsers(true);
|
||||
const result = await window.omsorg.users.list();
|
||||
if (result.ok) {
|
||||
const data = result.data ?? [];
|
||||
setUsers(data);
|
||||
setSelectedUserId((current) => current ?? data[0]?.id ?? null);
|
||||
} else {
|
||||
setError("Nutzer konnten nicht geladen werden.");
|
||||
}
|
||||
setIsLoadingUsers(false);
|
||||
}
|
||||
loadUsers();
|
||||
}, []);
|
||||
|
||||
const loadOverrides = useCallback(async (userId) => {
|
||||
if (!userId) return;
|
||||
setIsLoadingOverrides(true);
|
||||
setError(null);
|
||||
const result = await window.omsorg.users.listPermissionOverrides(userId);
|
||||
if (result.ok) {
|
||||
setOverrides(result.data ?? []);
|
||||
} else {
|
||||
setError("Rechte-Ausnahmen konnten nicht geladen werden.");
|
||||
}
|
||||
setIsLoadingOverrides(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadOverrides(selectedUserId);
|
||||
}, [selectedUserId, loadOverrides]);
|
||||
|
||||
async function handleAddOverride(event) {
|
||||
event.preventDefault();
|
||||
setIsAdding(true);
|
||||
setAddError(null);
|
||||
|
||||
const result = await window.omsorg.users.addPermissionOverride(selectedUserId, {
|
||||
module: newModule,
|
||||
action: newAction,
|
||||
effect: newEffect,
|
||||
});
|
||||
|
||||
setIsAdding(false);
|
||||
if (!result.ok) {
|
||||
setAddError("Override konnte nicht gespeichert werden.");
|
||||
return;
|
||||
}
|
||||
|
||||
await loadOverrides(selectedUserId);
|
||||
}
|
||||
|
||||
async function handleDelete(overrideId) {
|
||||
await window.omsorg.users.deletePermissionOverride(selectedUserId, overrideId);
|
||||
await loadOverrides(selectedUserId);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="settings-overrides-panel">
|
||||
{isLoadingUsers && <p>Lädt...</p>}
|
||||
{error && <p className="login-error">{error}</p>}
|
||||
|
||||
{!isLoadingUsers && (
|
||||
<div className="settings-roles-layout">
|
||||
<div className="settings-roles-list">
|
||||
<ul>
|
||||
{users.map((user) => (
|
||||
<li key={user.id}>
|
||||
<button
|
||||
type="button"
|
||||
className={user.id === selectedUserId ? "settings-role-item settings-role-item--active" : "settings-role-item"}
|
||||
onClick={() => setSelectedUserId(user.id)}
|
||||
>
|
||||
{user.username}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="settings-roles-detail">
|
||||
{!selectedUserId && <p>Kein Nutzer ausgewählt.</p>}
|
||||
|
||||
{selectedUserId && (
|
||||
<>
|
||||
{isLoadingOverrides && <p>Lädt...</p>}
|
||||
|
||||
{!isLoadingOverrides && overrides.length === 0 && <p>Keine individuellen Rechte-Ausnahmen.</p>}
|
||||
|
||||
{!isLoadingOverrides && overrides.length > 0 && (
|
||||
<ul className="debug-sessions-list">
|
||||
{overrides.map((o) => (
|
||||
<li key={o.id} className="debug-sessions-row">
|
||||
<div>
|
||||
<strong>
|
||||
{labelFor(MODULE_OPTIONS, o.module)} · {labelFor(ACTION_OPTIONS, o.action)}
|
||||
</strong>
|
||||
<p className="debug-sessions-meta">{labelFor(EFFECT_OPTIONS, o.effect)}</p>
|
||||
</div>
|
||||
{canEdit && (
|
||||
<OmsorgButton icon={Trash2} variant="danger" onClick={() => handleDelete(o.id)}>
|
||||
Entfernen
|
||||
</OmsorgButton>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{canEdit && (
|
||||
<form onSubmit={handleAddOverride} className="settings-new-override-form">
|
||||
<select value={newModule} onChange={(event) => setNewModule(event.target.value)}>
|
||||
{MODULE_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select value={newAction} onChange={(event) => setNewAction(event.target.value)}>
|
||||
{ACTION_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select value={newEffect} onChange={(event) => setNewEffect(event.target.value)}>
|
||||
{EFFECT_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<OmsorgButton icon={Plus} type="submit" variant="secondary" disabled={isAdding}>
|
||||
Override hinzufügen
|
||||
</OmsorgButton>
|
||||
</form>
|
||||
)}
|
||||
{addError && <p className="login-error">{addError}</p>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { ModuleType, PermissionAction, PermissionEffect } from "omsorgcore-client-ts";
|
||||
|
||||
// Die gültige Werte-Menge kommt jetzt aus dem generierten omsorgCore-Client (ModuleType/
|
||||
// PermissionAction/PermissionEffect sind echte Enums im Backend-Schema, siehe omsorgCore/CLAUDE.md,
|
||||
// Abschnitt "Rechtesystem") - nur die deutschen Anzeige-Labels bleiben hier im Frontend gepflegt.
|
||||
// Ein neuer Enum-Wert im Backend taucht nach Regenerieren des Clients automatisch in den Optionen
|
||||
// auf (Fallback-Label = technischer Wert), statt unbemerkt zu fehlen.
|
||||
const MODULE_LABELS = {
|
||||
[ModuleType.Employees]: "Mitarbeiter",
|
||||
[ModuleType.Facilities]: "Einrichtungen",
|
||||
[ModuleType.Contracts]: "Verträge",
|
||||
[ModuleType.Orders]: "Aufträge",
|
||||
[ModuleType.TimeEntries]: "Zeiterfassung",
|
||||
[ModuleType.Invoices]: "Rechnungen",
|
||||
[ModuleType.Recruiting]: "Recruiting",
|
||||
[ModuleType.Controlling]: "Controlling",
|
||||
[ModuleType.UserManagement]: "Nutzerverwaltung",
|
||||
[ModuleType.AuditLog]: "Audit-Log",
|
||||
};
|
||||
|
||||
const ACTION_LABELS = {
|
||||
[PermissionAction.View]: "Ansehen",
|
||||
[PermissionAction.Create]: "Anlegen",
|
||||
[PermissionAction.Edit]: "Bearbeiten",
|
||||
[PermissionAction.Delete]: "Löschen",
|
||||
[PermissionAction.Export]: "Export",
|
||||
[PermissionAction.Approve]: "Freigeben",
|
||||
};
|
||||
|
||||
const EFFECT_LABELS = {
|
||||
[PermissionEffect.Grant]: "Gewähren",
|
||||
[PermissionEffect.Revoke]: "Entziehen",
|
||||
};
|
||||
|
||||
function toOptions(enumObject, labels) {
|
||||
return Object.values(enumObject).map((value) => ({ value, label: labels[value] ?? value }));
|
||||
}
|
||||
|
||||
export const MODULE_OPTIONS = toOptions(ModuleType, MODULE_LABELS);
|
||||
export const ACTION_OPTIONS = toOptions(PermissionAction, ACTION_LABELS);
|
||||
export const EFFECT_OPTIONS = toOptions(PermissionEffect, EFFECT_LABELS);
|
||||
@@ -1 +0,0 @@
|
||||
:root{font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#eefbff;background:#06111d}*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at 20% 10%,rgba(104,210,223,.35),transparent 28%),linear-gradient(135deg,#07111d 0%,#0b2a35 45%,#11111f 100%);min-height:100vh}.loading{padding:40px}.app{display:flex;min-height:100vh}.sidebar{width:285px;padding:24px;background:rgba(5,15,26,.72);backdrop-filter:blur(18px);border-right:1px solid rgba(104,210,223,.16)}.brand{display:flex;gap:12px;align-items:center;margin-bottom:28px}.brand img{width:86px;height:auto}.brand b{display:block}.brand span,.muted,.row span,.sidecard span,header p{color:#a9c9d3;font-size:13px}nav{display:grid;gap:8px}button,.button{border:0;border-radius:14px;background:rgba(255,255,255,.08);color:#eefbff;padding:12px 14px;display:inline-flex;gap:10px;align-items:center;cursor:pointer;text-decoration:none}button:hover,.button:hover,nav button.active{background:linear-gradient(135deg,rgba(104,210,223,.30),rgba(104,210,223,.10));box-shadow:0 10px 30px rgba(0,0,0,.18)}nav button{width:100%;justify-content:flex-start}.sidecard{margin-top:30px;padding:14px;border:1px solid rgba(104,210,223,.14);border-radius:18px;background:rgba(255,255,255,.05);display:flex;gap:10px}main{flex:1;padding:28px 34px}header{display:flex;justify-content:space-between;align-items:center;margin-bottom:24px}h1,h2,h3{margin:0 0 8px}header h1{font-size:34px}.header-actions{display:flex;gap:10px}.bell{position:relative}.bell span{position:absolute;top:-6px;right:-6px;background:#68d2df;color:#11111f;border-radius:999px;padding:2px 7px;font-weight:800}.grid{display:grid;grid-template-columns:1.4fr 1fr;gap:18px}.wide{grid-column:1/-1}.card{border:1px solid rgba(104,210,223,.16);background:linear-gradient(180deg,rgba(255,255,255,.10),rgba(255,255,255,.045));border-radius:24px;padding:22px;box-shadow:0 24px 70px rgba(0,0,0,.22)}.hero{min-height:190px}.chips{display:flex;gap:10px;margin-top:22px}.chips span{background:rgba(104,210,223,.14);border:1px solid rgba(104,210,223,.20);padding:9px 12px;border-radius:999px}.warning{display:flex;width:100%;justify-content:flex-start;background:rgba(255,190,90,.14);border:1px solid rgba(255,190,90,.20);margin:8px 0}.quick{display:flex;flex-wrap:wrap;gap:10px}.form{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:10px;margin-bottom:18px}input,select{width:100%;border:1px solid rgba(104,210,223,.18);border-radius:14px;padding:12px;background:rgba(0,0,0,.2);color:#eefbff}label{display:grid;gap:7px;color:#a9c9d3}.list{display:grid;gap:10px}.row{display:flex;justify-content:space-between;align-items:center;padding:16px 18px;border:1px solid rgba(104,210,223,.12);border-radius:18px;background:rgba(255,255,255,.055);cursor:pointer}.row b{display:block}.overlay{position:fixed;inset:0;background:rgba(0,0,0,.55);display:grid;place-items:center;padding:20px}.modal{width:min(620px,95vw);background:#0b1a2a;border:1px solid rgba(104,210,223,.22);border-radius:24px;padding:24px;box-shadow:0 30px 80px rgba(0,0,0,.45);position:relative}.close{position:absolute;top:14px;right:14px;font-size:22px;padding:8px 12px}.toast{position:fixed;right:24px;bottom:24px;background:#68d2df;color:#11111f;border-radius:16px;padding:14px 18px;font-weight:800}.big{font-size:38px;font-weight:900;color:#68d2df;margin:20px 0}code{display:inline-block;padding:12px;border-radius:12px;background:rgba(0,0,0,.28);color:#bdf6ff;word-break:break-all}a{color:#68d2df}
|
||||
+161
-1
@@ -169,6 +169,17 @@ button {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.menu-button.active {
|
||||
background:
|
||||
linear-gradient(
|
||||
135deg,
|
||||
rgba(104, 210, 223, 0.22),
|
||||
rgba(104, 210, 223, 0.08)
|
||||
);
|
||||
border: 1px solid var(--omsorg-border-active);
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -1167,7 +1178,30 @@ button.omsorg-button--danger {
|
||||
.employee-tab-content p {
|
||||
color: var(--omsorg-text-secondary);
|
||||
line-height: 1.6;
|
||||
}/* ==========================================================
|
||||
}
|
||||
|
||||
.facility-contacts-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.facility-contact-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
|
||||
padding: 12px 14px;
|
||||
|
||||
border: 1px solid var(--omsorg-border);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.facility-contact-row p {
|
||||
margin: 4px 0 0;
|
||||
color: var(--omsorg-text-secondary);
|
||||
}
|
||||
/* ==========================================================
|
||||
EMPLOYEE TAB CONTENT
|
||||
========================================================== */
|
||||
|
||||
@@ -1605,3 +1639,129 @@ button.omsorg-button--danger {
|
||||
font-family: inherit;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.audit-log-details {
|
||||
font-family: monospace;
|
||||
font-size: 0.8rem;
|
||||
color: var(--omsorg-text-muted);
|
||||
margin: 6px 0 0;
|
||||
word-break: break-word;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.settings-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
border-bottom: 1px solid var(--omsorg-border);
|
||||
}
|
||||
|
||||
.settings-tab {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 10px 4px;
|
||||
margin-bottom: -1px;
|
||||
color: var(--omsorg-text-muted);
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
}
|
||||
|
||||
.settings-tab--active {
|
||||
color: var(--omsorg-text-primary);
|
||||
border-bottom-color: var(--omsorg-primary, currentColor);
|
||||
}
|
||||
|
||||
.settings-panel-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.settings-panel-heading h4 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.settings-roles-layout {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.settings-roles-list {
|
||||
flex: 0 0 220px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.settings-roles-list ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.settings-role-item {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--omsorg-border);
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: var(--omsorg-text-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.settings-role-item--active {
|
||||
border-color: var(--omsorg-primary, currentColor);
|
||||
}
|
||||
|
||||
.settings-new-role-form {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.settings-roles-detail {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.settings-permission-matrix-scroll {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.settings-permission-matrix table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.settings-permission-matrix th,
|
||||
.settings-permission-matrix td {
|
||||
padding: 8px 10px;
|
||||
text-align: center;
|
||||
border-bottom: 1px solid var(--omsorg-border);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.settings-permission-matrix td:first-child,
|
||||
.settings-permission-matrix th:first-child {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.settings-permission-matrix-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.settings-new-override-form {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user