Migrate omsorgapp to browser SPA, add Docker/CI build setup
Docker-Images bauen und veröffentlichen / build (, omsorgCore/Dockerfile, omsorgcore) (push) Successful in 3s
Docker-Images bauen und veröffentlichen / build (, omsorgWeb/Dockerfile, omsorgweb) (push) Failing after 4s
Docker-Images bauen und veröffentlichen / build (VITE_OMSORG_CORE_URL=${{ vars.OMSORG_CORE_PUBLIC_URL }}, omsorgapp/Dockerfile, omsorgapp) (push) Successful in 3s

- omsorgapp: drop Electron, run as a plain Vite/React browser app; refresh
  token moves to an HttpOnly cookie (omsorgCore), CORS added for the new
  browser origin, document download/preview switched to Blob-based browser
  APIs.
- Add Dockerfiles for omsorgCore, omsorgapp, and omsorgWeb, a docker-compose.yml
  wiring Postgres/MySQL/all three apps together, and a Gitea Actions workflow
  that builds and pushes images to the repo's container registry on push to
  main and on version tags.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Felix Kemmler
2026-08-10 17:42:45 +02:00
co-authored by Claude Sonnet 5
parent e9e96a57dc
commit 598dfcd38a
461 changed files with 59753 additions and 2859 deletions
+40
View File
@@ -0,0 +1,40 @@
import { AbsencesApi } from "omsorgcore-client-ts";
import { configFor, callApi } from "./apiClientHelpers.js";
// Kapselt /api/absences von omsorgCore (AbsencesController) über den generierten Client
// (omsorgcore-client-ts). Anlegen (mit Date-Feldern) passiert nur über omsorgWeb/mitarbeiter-app,
// omsorgapp braucht hier Lesen + Bearbeiten (solange "Eingereicht") + Entscheiden (Genehmigen/Ablehnen).
// startDate/endDate müssen als Date-Objekte übergeben werden - siehe ordersApi.js für dasselbe Muster.
function toDatePayload(payload) {
return {
...payload,
startDate: payload.startDate ? new Date(payload.startDate) : payload.startDate,
endDate: payload.endDate ? new Date(payload.endDate) : payload.endDate
};
}
export async function listAbsences(accessToken, { status, type, employeeId, page = 1, pageSize = 20 } = {}) {
const api = new AbsencesApi(configFor(accessToken));
return callApi(api.apiAbsencesGetRaw({ status, type, employeeId, page, pageSize }));
}
export async function getAbsence(accessToken, id) {
const api = new AbsencesApi(configFor(accessToken));
return callApi(api.apiAbsencesIdGetRaw({ id }));
}
export async function updateAbsence(accessToken, id, payload) {
const api = new AbsencesApi(configFor(accessToken));
return callApi(api.apiAbsencesIdPutRaw({ id, updateAbsenceRequest: toDatePayload(payload) }));
}
export async function decideAbsence(accessToken, id, payload) {
const api = new AbsencesApi(configFor(accessToken));
return callApi(api.apiAbsencesIdDecisionPostRaw({ id, absenceDecisionRequest: payload }));
}
export async function deleteAbsence(accessToken, id) {
const api = new AbsencesApi(configFor(accessToken));
return callApi(api.apiAbsencesIdDeleteRaw({ id }));
}
+33
View File
@@ -0,0 +1,33 @@
import { Configuration } from "omsorgcore-client-ts";
import { API_BASE } from "./config.js";
// Baut die Configuration für eine generierte *Api-Klasse (omsorgcore-client-ts).
// credentials:"include" ist nötig, damit der Browser die HttpOnly-Refresh-Token-Cookie
// auf /api/auth/* mitschickt bzw. entgegennimmt (siehe omsorgCore/CLAUDE.md, "Auth-Flow").
export function configFor(accessToken) {
return new Configuration({ basePath: API_BASE, accessToken, credentials: "include" });
}
// Wandelt den Aufruf einer generierten `...Raw()`-Methode in denselben
// Vertrag um, den vorher httpClient.request(...) lieferte: { ok, status, data, error? }.
// Generierte Clients werfen bei Nicht-2xx eine ResponseError-Exception statt ein
// Ergebnisobjekt zurückzugeben - das fangen wir hier zentral ab.
export async function callApi(rawPromise) {
try {
const response = await rawPromise;
const data = await response.value();
return { ok: true, status: response.raw.status, data: data === undefined ? null : data };
} catch (err) {
if (err && err.name === "ResponseError") {
const status = err.response.status;
let data = null;
try {
data = await err.response.json();
} catch {
data = null;
}
return { ok: false, status, data };
}
return { ok: false, status: 0, data: null, error: err && err.message };
}
}
+23
View File
@@ -0,0 +1,23 @@
import { AuditLogApi } from "omsorgcore-client-ts";
import { configFor, callApi } from "./apiClientHelpers.js";
// Kapselt GET /api/audit-log (AuditLogController) über den generierten Client
// (omsorgcore-client-ts) - rein lesend, keine weiteren Verben.
export async function listAuditLog(
accessToken,
{ page = 1, pageSize = 50, entityType, entityId, actorUserId, category, fromUtc, toUtc } = {}
) {
const api = new AuditLogApi(configFor(accessToken));
return callApi(
api.apiAuditLogGetRaw({
page,
pageSize,
entityType: entityType || undefined,
entityId: entityId || undefined,
actorUserId: actorUserId || undefined,
category: category || undefined,
fromUtc: fromUtc ? new Date(fromUtc) : undefined,
toUtc: toUtc ? new Date(toUtc) : undefined
})
);
}
+75
View File
@@ -0,0 +1,75 @@
import { AuthApi } from "omsorgcore-client-ts";
import { configFor, callApi } from "./apiClientHelpers.js";
// Kapselt /api/auth/* von omsorgCore über den generierten Client (omsorgcore-client-ts).
// refresh/logout brauchen keinen Refresh-Token-Parameter mehr - er steckt in der
// HttpOnly-Cookie, die der Browser dank credentials:"include" automatisch mitschickt.
function toTokenPair(data) {
return {
accessToken: data.accessToken,
expiresAt: data.expiresAt,
mustChangePassword: !!data.mustChangePassword
};
}
export async function login(username, password) {
const api = new AuthApi(configFor(undefined));
const result = await callApi(api.apiAuthLoginPostRaw({ loginRequest: { username, password } }));
if (!result.ok) {
return { ok: false, status: result.status, error: result.error || result.data?.error };
}
return { ok: true, ...toTokenPair(result.data) };
}
export async function refresh() {
const api = new AuthApi(configFor(undefined));
const result = await callApi(api.apiAuthRefreshPostRaw());
if (!result.ok) return { ok: false, status: result.status };
return { ok: true, ...toTokenPair(result.data) };
}
export async function logout() {
const api = new AuthApi(configFor(undefined));
const result = await callApi(api.apiAuthLogoutPostRaw());
return { ok: result.ok, status: result.status };
}
export async function me(accessToken) {
const api = new AuthApi(configFor(accessToken));
const result = await callApi(api.apiAuthMeGetRaw());
if (!result.ok) return { ok: false, status: result.status };
return { ok: true, username: result.data.username, role: result.data.role, permissions: result.data.permissions };
}
export async function requestPasswordReset(username) {
const api = new AuthApi(configFor(undefined));
const result = await callApi(api.apiAuthForgotPasswordRequestPostRaw({ forgotPasswordRequestRequest: { username } }));
if (!result.ok) return { ok: false, status: result.status };
return { ok: true, status: result.data.status };
}
export async function verifyPasswordResetCode(username, pin) {
const api = new AuthApi(configFor(undefined));
const result = await callApi(api.apiAuthForgotPasswordVerifyPostRaw({ forgotPasswordVerifyRequest: { username, pin } }));
if (!result.ok) return { ok: false, status: result.status, error: result.data?.error };
return { ok: true, resetToken: result.data.resetToken };
}
export async function resetPassword(resetToken, newPassword) {
const api = new AuthApi(configFor(undefined));
const result = await callApi(api.apiAuthForgotPasswordResetPostRaw({ forgotPasswordResetRequest: { resetToken, newPassword } }));
return { ok: result.ok, status: result.status, data: result.data };
}
export async function changePassword(accessToken, currentPassword, newPassword) {
const api = new AuthApi(configFor(accessToken));
const result = await callApi(api.apiAuthChangePasswordPostRaw({ changePasswordRequest: { currentPassword, newPassword } }));
return { ok: result.ok, status: result.status, data: result.data };
}
export async function getPasswordPolicy() {
const api = new AuthApi(configFor(undefined));
const result = await callApi(api.apiAuthPasswordPolicyGetRaw());
if (!result.ok) return { ok: false, status: result.status };
return { ok: true, minLength: result.data.minLength };
}
+4
View File
@@ -0,0 +1,4 @@
// Einzige Stelle im Projekt, die die Adresse von omsorgCore kennt.
// Default passt zum lokalen `dotnet run --project src/OmsorgCore.Api` (http-Profil, Port 5245),
// überschreibbar per Vite-Env-Variable (VITE_OMSORG_CORE_URL) für andere Umgebungen.
export const API_BASE = import.meta.env.VITE_OMSORG_CORE_URL || "http://localhost:5245";
+25
View File
@@ -0,0 +1,25 @@
import { ContractsApi } from "omsorgcore-client-ts";
import { configFor, callApi } from "./apiClientHelpers.js";
// Kapselt /api/contracts von omsorgCore (ContractsController) über den generierten
// Client (omsorgcore-client-ts). Feldnamen sind camelCase, da ASP.NET Core
// standardmäßig camelCase-JSON ausgibt.
export async function listContracts(accessToken, { employeeId, facilityId, search, status, page = 1, pageSize = 20 } = {}) {
const api = new ContractsApi(configFor(accessToken));
return callApi(api.apiContractsGetRaw({ employeeId, facilityId, search, status, page, pageSize }));
}
export async function createContract(accessToken, payload) {
const api = new ContractsApi(configFor(accessToken));
return callApi(api.apiContractsPostRaw({ createContractRequest: payload }));
}
export async function updateContract(accessToken, id, payload) {
const api = new ContractsApi(configFor(accessToken));
return callApi(api.apiContractsIdPutRaw({ id, updateContractRequest: payload }));
}
export async function deleteContract(accessToken, id) {
const api = new ContractsApi(configFor(accessToken));
return callApi(api.apiContractsIdDeleteRaw({ id }));
}
+49
View File
@@ -0,0 +1,49 @@
import { DocumentsApi } from "omsorgcore-client-ts";
import { configFor, callApi } from "./apiClientHelpers.js";
import { API_BASE } from "./config.js";
// Kapselt /api/documents von omsorgCore (DocumentsController). Der generierte Client wird für
// list/upload/delete genutzt; der Download-Endpoint NICHT über DocumentsApi (dessen
// apiDocumentsIdDownloadGetRaw ist als VoidApiResponse generiert und verwirft den Response-Body),
// sondern per direktem fetch, analog zu genericApi.js für nicht-generierte Endpunkte.
export async function listDocuments(accessToken, { entityType, entityId } = {}) {
const api = new DocumentsApi(configFor(accessToken));
return callApi(api.apiDocumentsGetRaw({ entityType, entityId }));
}
export async function uploadDocument(accessToken, { entityType, entityId, category, description, fileName, contentType, data }) {
const api = new DocumentsApi(configFor(accessToken));
// data ist ein ArrayBuffer (aus file.arrayBuffer() im UploadDocumentDialog) - Blob
// akzeptiert das im Browser direkt, kein Buffer.from(...) nötig (das war Node-spezifisch).
const file = new Blob([data], { type: contentType || "application/octet-stream" });
return callApi(api.apiDocumentsPostRaw({ entityType, entityId, category, description, file }));
}
export async function updateDocument(accessToken, id, payload) {
const api = new DocumentsApi(configFor(accessToken));
return callApi(api.apiDocumentsIdPutRaw({ id, updateDocumentRequest: payload }));
}
export async function deleteDocument(accessToken, id) {
const api = new DocumentsApi(configFor(accessToken));
return callApi(api.apiDocumentsIdDeleteRaw({ id }));
}
export async function downloadDocument(accessToken, id) {
const headers = {};
if (accessToken) headers.Authorization = `Bearer ${accessToken}`;
let response;
try {
response = await fetch(`${API_BASE}/api/documents/${id}/download`, { headers });
} catch (err) {
return { ok: false, status: 0, data: null, error: err.message };
}
if (!response.ok) {
return { ok: false, status: response.status, data: null };
}
const blob = await response.blob();
return { ok: true, status: response.status, data: { blob } };
}
+30
View File
@@ -0,0 +1,30 @@
import { EmployeesApi } from "omsorgcore-client-ts";
import { configFor, callApi } from "./apiClientHelpers.js";
// Kapselt /api/employees von omsorgCore (EmployeesController) über den generierten
// Client (omsorgcore-client-ts). Feldnamen sind camelCase, da ASP.NET Core
// standardmäßig camelCase-JSON ausgibt.
export async function listEmployees(accessToken, { search, status, employmentType, page = 1, pageSize = 20 } = {}) {
const api = new EmployeesApi(configFor(accessToken));
return callApi(api.apiEmployeesGetRaw({ search, status, employmentType, page, pageSize }));
}
export async function createEmployee(accessToken, payload) {
const api = new EmployeesApi(configFor(accessToken));
return callApi(api.apiEmployeesPostRaw({ createEmployeeRequest: payload }));
}
export async function getEmployee(accessToken, id) {
const api = new EmployeesApi(configFor(accessToken));
return callApi(api.apiEmployeesIdGetRaw({ id }));
}
export async function updateEmployee(accessToken, id, payload) {
const api = new EmployeesApi(configFor(accessToken));
return callApi(api.apiEmployeesIdPutRaw({ id, updateEmployeeRequest: payload }));
}
export async function deleteEmployee(accessToken, id) {
const api = new EmployeesApi(configFor(accessToken));
return callApi(api.apiEmployeesIdDeleteRaw({ id }));
}
+30
View File
@@ -0,0 +1,30 @@
import { FacilitiesApi } from "omsorgcore-client-ts";
import { configFor, callApi } from "./apiClientHelpers.js";
// Kapselt /api/facilities von omsorgCore (FacilitiesController) über den generierten
// Client (omsorgcore-client-ts). Feldnamen sind camelCase, da ASP.NET Core
// standardmäßig camelCase-JSON ausgibt.
export async function listFacilities(accessToken, { search, crmStatus, followUpDueOnly, page = 1, pageSize = 20 } = {}) {
const api = new FacilitiesApi(configFor(accessToken));
return callApi(api.apiFacilitiesGetRaw({ search, crmStatus, followUpDueOnly, page, pageSize }));
}
export async function createFacility(accessToken, payload) {
const api = new FacilitiesApi(configFor(accessToken));
return callApi(api.apiFacilitiesPostRaw({ createFacilityRequest: payload }));
}
export async function getFacility(accessToken, id) {
const api = new FacilitiesApi(configFor(accessToken));
return callApi(api.apiFacilitiesIdGetRaw({ id }));
}
export async function updateFacility(accessToken, id, payload) {
const api = new FacilitiesApi(configFor(accessToken));
return callApi(api.apiFacilitiesIdPutRaw({ id, updateFacilityRequest: payload }));
}
export async function deleteFacility(accessToken, id) {
const api = new FacilitiesApi(configFor(accessToken));
return callApi(api.apiFacilitiesIdDeleteRaw({ id }));
}
+25
View File
@@ -0,0 +1,25 @@
import { FacilityContactsApi } from "omsorgcore-client-ts";
import { configFor, callApi } from "./apiClientHelpers.js";
// Kapselt /api/facilities/{facilityId}/contacts von omsorgCore (FacilityContactsController)
// über den generierten Client (omsorgcore-client-ts). Ansprechpartner sind eine
// 1:n-Unterressource von Facility, kein eigenständiger Endpunkt.
export async function listFacilityContacts(accessToken, facilityId) {
const api = new FacilityContactsApi(configFor(accessToken));
return callApi(api.apiFacilitiesFacilityIdContactsGetRaw({ facilityId }));
}
export async function createFacilityContact(accessToken, facilityId, payload) {
const api = new FacilityContactsApi(configFor(accessToken));
return callApi(api.apiFacilitiesFacilityIdContactsPostRaw({ facilityId, createFacilityContactRequest: payload }));
}
export async function updateFacilityContact(accessToken, facilityId, id, payload) {
const api = new FacilityContactsApi(configFor(accessToken));
return callApi(api.apiFacilitiesFacilityIdContactsIdPutRaw({ facilityId, id, updateFacilityContactRequest: payload }));
}
export async function deleteFacilityContact(accessToken, facilityId, id) {
const api = new FacilityContactsApi(configFor(accessToken));
return callApi(api.apiFacilitiesFacilityIdContactsIdDeleteRaw({ facilityId, id }));
}
@@ -0,0 +1,25 @@
import { FacilityQualificationRatesApi } from "omsorgcore-client-ts";
import { configFor, callApi } from "./apiClientHelpers.js";
// Kapselt /api/facilities/{facilityId}/qualification-rates von omsorgCore
// (FacilityQualificationRatesController) über den generierten Client (omsorgcore-client-ts).
// Qualifikationsabhängige Preise sind eine 1:n-Unterressource von Facility (FR-EIN-4).
export async function listFacilityQualificationRates(accessToken, facilityId) {
const api = new FacilityQualificationRatesApi(configFor(accessToken));
return callApi(api.apiFacilitiesFacilityIdQualificationRatesGetRaw({ facilityId }));
}
export async function createFacilityQualificationRate(accessToken, facilityId, payload) {
const api = new FacilityQualificationRatesApi(configFor(accessToken));
return callApi(api.apiFacilitiesFacilityIdQualificationRatesPostRaw({ facilityId, createFacilityQualificationRateRequest: payload }));
}
export async function updateFacilityQualificationRate(accessToken, facilityId, id, payload) {
const api = new FacilityQualificationRatesApi(configFor(accessToken));
return callApi(api.apiFacilitiesFacilityIdQualificationRatesIdPutRaw({ facilityId, id, updateFacilityQualificationRateRequest: payload }));
}
export async function deleteFacilityQualificationRate(accessToken, facilityId, id) {
const api = new FacilityQualificationRatesApi(configFor(accessToken));
return callApi(api.apiFacilitiesFacilityIdQualificationRatesIdDeleteRaw({ facilityId, id }));
}
+33
View File
@@ -0,0 +1,33 @@
import { API_BASE } from "./config.js";
// Ersatz für httpClient.cjs + den generischen api:get/api:post-IPC-Proxy - für Ressourcen
// ohne eigene <kategorie>Api.js-Datei (aktuell nur /api/admin/sessions*, /api/admin/email/*,
// siehe DebugSessionsPage.jsx).
export async function request(method, path, accessToken, body) {
const headers = { "Content-Type": "application/json" };
if (accessToken) headers.Authorization = `Bearer ${accessToken}`;
let response;
try {
response = await fetch(`${API_BASE}${path}`, {
method,
headers,
body: body !== undefined ? JSON.stringify(body) : undefined
});
} catch (err) {
return { ok: false, status: 0, data: null, error: err.message };
}
if (response.status === 204) {
return { ok: true, status: 204, data: null };
}
let data = null;
try {
data = await response.json();
} catch {
data = null;
}
return { ok: response.ok, status: response.status, data };
}
+213
View File
@@ -0,0 +1,213 @@
import * as session from "./session.js";
import * as authApi from "./authApi.js";
import * as genericApi from "./genericApi.js";
import * as employeesApi from "./employeesApi.js";
import * as facilitiesApi from "./facilitiesApi.js";
import * as facilityContactsApi from "./facilityContactsApi.js";
import * as facilityQualificationRatesApi from "./facilityQualificationRatesApi.js";
import * as ordersApi from "./ordersApi.js";
import * as absencesApi from "./absencesApi.js";
import * as timeEntriesApi from "./timeEntriesApi.js";
import * as usersApi from "./usersApi.js";
import * as rolesApi from "./rolesApi.js";
import * as auditLogApi from "./auditLogApi.js";
import * as valueListsApi from "./valueListsApi.js";
import * as trashApi from "./trashApi.js";
import * as contractsApi from "./contractsApi.js";
import * as documentsApi from "./documentsApi.js";
// Ersetzt electron/preload.cjs' contextBridge.exposeInMainWorld('omsorg', {...}) - dieselbe
// Oberfläche als reines Browser-Objekt statt IPC-Bridge, damit keine der 76+ Aufrufstellen
// unter src/modules/** angefasst werden muss (siehe Plan "Electron -> Browser-SPA-Migration").
function withAuth(fn) {
return (...args) => session.withAuthRetry(() => fn(session.getAccessToken(), ...args));
}
// Ersetzt main.cjs' documents:download-Handler (dort: dialog.showSaveDialog + fs.writeFileSync).
async function downloadDocumentAsFile(id, fileName) {
const result = await session.withAuthRetry(() => documentsApi.downloadDocument(session.getAccessToken(), id));
if (!result.ok) return result;
const url = URL.createObjectURL(result.data.blob);
const link = document.createElement("a");
link.href = url;
link.download = fileName;
link.click();
URL.revokeObjectURL(url);
return { ok: true };
}
export function buildOmsorgApi() {
return {
auth: {
login: async (username, password) => {
const result = await authApi.login(username, password);
if (!result.ok) {
const error = result.status === 0 ? "network_error" : result.status === 429 ? "too_many_attempts" : "invalid_credentials";
return { success: false, error };
}
session.applySession(result);
const snapshot = session.getSessionSnapshot();
return { success: true, user: snapshot.user, mustChangePassword: snapshot.mustChangePassword };
},
logout: async () => {
try {
await authApi.logout();
} catch {
/* best effort */
}
session.clearSession();
return { success: true };
},
getSession: async () => {
if (!session.getAccessToken()) {
await session.bootstrapSession();
}
return session.getSessionSnapshot();
},
onSessionChanged: session.onSessionChanged,
requestPasswordReset: async (username) => {
const result = await authApi.requestPasswordReset(username);
if (!result.ok) return { success: false, error: "network_error" };
return { success: true, status: result.status };
},
verifyPasswordResetCode: async (username, pin) => {
const result = await authApi.verifyPasswordResetCode(username, pin);
if (!result.ok) return { success: false, error: result.error || "invalid_or_expired" };
return { success: true, resetToken: result.resetToken };
},
resetPassword: async (resetToken, newPassword) => {
const result = await authApi.resetPassword(resetToken, newPassword);
if (!result.ok) {
const error = result.status === 400 ? "password_too_short" : "invalid_or_expired";
return { success: false, error, message: result.status === 400 && typeof result.data === "string" ? result.data : undefined };
}
return { success: true };
},
changePassword: async (currentPassword, newPassword) => {
if (!session.getAccessToken()) return { success: false, error: "not_authenticated" };
const result = await authApi.changePassword(session.getAccessToken(), currentPassword, newPassword);
if (!result.ok) {
const error = result.status === 401 ? "invalid_current_password" : result.status === 400 ? "password_too_short" : "unknown";
return { success: false, error, message: result.status === 400 && typeof result.data === "string" ? result.data : undefined };
}
session.clearSession();
return { success: true };
},
getPasswordPolicy: async () => {
const result = await authApi.getPasswordPolicy();
if (!result.ok) return { success: false };
return { success: true, minLength: result.minLength };
}
},
api: {
get: (path) => session.withAuthRetry(() => genericApi.request("GET", path, session.getAccessToken())),
post: (path, body) => session.withAuthRetry(() => genericApi.request("POST", path, session.getAccessToken(), body))
},
employees: {
list: withAuth(employeesApi.listEmployees),
create: withAuth(employeesApi.createEmployee),
update: withAuth(employeesApi.updateEmployee),
delete: withAuth(employeesApi.deleteEmployee)
},
facilities: {
list: withAuth(facilitiesApi.listFacilities),
create: withAuth(facilitiesApi.createFacility),
update: withAuth(facilitiesApi.updateFacility),
delete: withAuth(facilitiesApi.deleteFacility)
},
facilityContacts: {
list: withAuth(facilityContactsApi.listFacilityContacts),
create: withAuth(facilityContactsApi.createFacilityContact),
update: withAuth(facilityContactsApi.updateFacilityContact),
delete: withAuth(facilityContactsApi.deleteFacilityContact)
},
facilityQualificationRates: {
list: withAuth(facilityQualificationRatesApi.listFacilityQualificationRates),
create: withAuth(facilityQualificationRatesApi.createFacilityQualificationRate),
update: withAuth(facilityQualificationRatesApi.updateFacilityQualificationRate),
delete: withAuth(facilityQualificationRatesApi.deleteFacilityQualificationRate)
},
orders: {
list: withAuth(ordersApi.listOrders),
create: withAuth(ordersApi.createOrder),
get: withAuth(ordersApi.getOrder),
update: withAuth(ordersApi.updateOrder),
delete: withAuth(ordersApi.deleteOrder)
},
absences: {
list: withAuth(absencesApi.listAbsences),
get: withAuth(absencesApi.getAbsence),
update: withAuth(absencesApi.updateAbsence),
decide: withAuth(absencesApi.decideAbsence),
delete: withAuth(absencesApi.deleteAbsence)
},
timeEntries: {
list: withAuth(timeEntriesApi.listTimeEntries),
get: withAuth(timeEntriesApi.getTimeEntry),
update: withAuth(timeEntriesApi.updateTimeEntry),
submit: withAuth(timeEntriesApi.submitTimeEntry),
decide: withAuth(timeEntriesApi.decideTimeEntry),
delete: withAuth(timeEntriesApi.deleteTimeEntry)
},
users: {
list: withAuth(usersApi.listUsers),
create: withAuth(usersApi.createUser),
listPermissionOverrides: withAuth(usersApi.listPermissionOverrides),
addPermissionOverride: withAuth(usersApi.addPermissionOverride),
deletePermissionOverride: withAuth(usersApi.deletePermissionOverride),
update: withAuth(usersApi.updateUser),
resetPassword: withAuth(usersApi.resetPassword)
},
roles: {
list: withAuth(rolesApi.listRoles),
get: withAuth(rolesApi.getRole),
create: withAuth(rolesApi.createRole),
updatePermissions: withAuth(rolesApi.updateRolePermissions)
},
auditLog: {
list: withAuth(auditLogApi.listAuditLog)
},
valueLists: {
list: withAuth(valueListsApi.listValueLists),
listItems: withAuth(valueListsApi.listItems),
createItem: withAuth(valueListsApi.createItem),
updateItem: withAuth(valueListsApi.updateItem),
deleteItem: withAuth(valueListsApi.deleteItem),
getUsages: withAuth(valueListsApi.getUsages),
listTransitions: withAuth(valueListsApi.listTransitions),
replaceTransitions: withAuth(valueListsApi.replaceTransitions)
},
trash: {
listEmployees: withAuth(trashApi.listDeletedEmployees),
restoreEmployee: withAuth(trashApi.restoreEmployee),
listFacilities: withAuth(trashApi.listDeletedFacilities),
restoreFacility: withAuth(trashApi.restoreFacility),
listContracts: withAuth(trashApi.listDeletedContracts),
restoreContract: withAuth(trashApi.restoreContract),
listOrders: withAuth(trashApi.listDeletedOrders),
restoreOrder: withAuth(trashApi.restoreOrder),
listFacilityContacts: withAuth(trashApi.listDeletedFacilityContacts),
restoreFacilityContact: withAuth(trashApi.restoreFacilityContact),
listFacilityQualificationRates: withAuth(trashApi.listDeletedFacilityQualificationRates),
restoreFacilityQualificationRate: withAuth(trashApi.restoreFacilityQualificationRate),
listAbsences: withAuth(trashApi.listDeletedAbsences),
restoreAbsence: withAuth(trashApi.restoreAbsence),
listTimeEntries: withAuth(trashApi.listDeletedTimeEntries),
restoreTimeEntry: withAuth(trashApi.restoreTimeEntry)
},
contracts: {
list: withAuth(contractsApi.listContracts),
create: withAuth(contractsApi.createContract),
update: withAuth(contractsApi.updateContract),
delete: withAuth(contractsApi.deleteContract)
},
documents: {
list: withAuth(documentsApi.listDocuments),
upload: withAuth(documentsApi.uploadDocument),
update: withAuth(documentsApi.updateDocument),
delete: withAuth(documentsApi.deleteDocument),
download: (id, fileName) => downloadDocumentAsFile(id, fileName),
view: withAuth(documentsApi.downloadDocument)
}
};
}
+45
View File
@@ -0,0 +1,45 @@
import { OrdersApi } from "omsorgcore-client-ts";
import { configFor, callApi } from "./apiClientHelpers.js";
// Kapselt /api/orders von omsorgCore (OrdersController) über den generierten
// Client (omsorgcore-client-ts). startDate/endDate müssen als Date-Objekte
// übergeben werden - die generierten ...ToJSON-Funktionen rufen .toISOString()
// darauf auf, ein reiner "YYYY-MM-DD"-String aus einem <input type="date">
// würde dabei crashen (siehe auditLogApi.js für dasselbe Muster bei Filtern).
function toDatePayload(payload) {
return {
...payload,
startDate: payload.startDate ? new Date(payload.startDate) : payload.startDate,
endDate: payload.endDate ? new Date(payload.endDate) : payload.endDate
};
}
export async function listOrders(
accessToken,
{ search, statusId, facilityId, priority, requiredQualification, shiftType, page = 1, pageSize = 20 } = {}
) {
const api = new OrdersApi(configFor(accessToken));
return callApi(
api.apiOrdersGetRaw({ search, statusId, facilityId, priority, requiredQualification, shiftType, page, pageSize })
);
}
export async function createOrder(accessToken, payload) {
const api = new OrdersApi(configFor(accessToken));
return callApi(api.apiOrdersPostRaw({ createOrderRequest: toDatePayload(payload) }));
}
export async function getOrder(accessToken, id) {
const api = new OrdersApi(configFor(accessToken));
return callApi(api.apiOrdersIdGetRaw({ id }));
}
export async function updateOrder(accessToken, id, payload) {
const api = new OrdersApi(configFor(accessToken));
return callApi(api.apiOrdersIdPutRaw({ id, updateOrderRequest: toDatePayload(payload) }));
}
export async function deleteOrder(accessToken, id) {
const api = new OrdersApi(configFor(accessToken));
return callApi(api.apiOrdersIdDeleteRaw({ id }));
}
+24
View File
@@ -0,0 +1,24 @@
import { RolesApi } from "omsorgcore-client-ts";
import { configFor, callApi } from "./apiClientHelpers.js";
// Kapselt /api/roles von omsorgCore (RolesController) über den generierten Client
// (omsorgcore-client-ts).
export async function listRoles(accessToken) {
const api = new RolesApi(configFor(accessToken));
return callApi(api.apiRolesGetRaw());
}
export async function getRole(accessToken, roleId) {
const api = new RolesApi(configFor(accessToken));
return callApi(api.apiRolesIdGetRaw({ id: roleId }));
}
export async function createRole(accessToken, payload) {
const api = new RolesApi(configFor(accessToken));
return callApi(api.apiRolesPostRaw({ createRoleRequest: payload }));
}
export async function updateRolePermissions(accessToken, roleId, payload) {
const api = new RolesApi(configFor(accessToken));
return callApi(api.apiRolesIdPermissionsPutRaw({ id: roleId, updateRolePermissionsRequest: payload }));
}
+128
View File
@@ -0,0 +1,128 @@
// Browser-Ersatz für das frühere Session-Management im Electron-Hauptprozess (main.cjs):
// Access-Token nur als Modul-Variable im Speicher (verschwindet bei Reload/Tab-Schließen),
// Refresh-Token liegt jetzt als HttpOnly-Cookie beim Server (nie per JS lesbar, siehe
// omsorgCore/CLAUDE.md "Auth-Flow") - refreshSilently() schickt dafür nur noch
// credentials:"include" (via authApi.refresh()), ohne den Token selbst zu kennen.
import { refresh as refreshRequest, me as meRequest } from "./authApi.js";
const REFRESH_BUFFER_MS = 2 * 60 * 1000;
let accessToken = null;
let expiresAt = null;
let user = null;
let mustChangePassword = false;
let refreshTimer = null;
const listeners = new Set();
export function getSessionSnapshot() {
return { isAuthenticated: !!accessToken, user, mustChangePassword };
}
function notify() {
const snapshot = getSessionSnapshot();
listeners.forEach((cb) => cb(snapshot));
}
export function getAccessToken() {
return accessToken;
}
export function onSessionChanged(callback) {
listeners.add(callback);
return () => listeners.delete(callback);
}
// Rein für die Anzeige "angemeldet als ..." - die Signaturprüfung passiert serverseitig
// bei jedem authentifizierten Aufruf, hier wird nichts sicherheitsrelevantes entschieden.
function decodeJwtClaims(token) {
try {
const payload = token.split(".")[1];
const base64 = payload.replace(/-/g, "+").replace(/_/g, "/");
const json = decodeURIComponent(
atob(base64)
.split("")
.map((c) => "%" + c.charCodeAt(0).toString(16).padStart(2, "0"))
.join("")
);
return JSON.parse(json);
} catch {
return {};
}
}
function userFromAccessToken(token) {
const claims = decodeJwtClaims(token);
return {
username: claims["name"] || claims["http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name"] || null,
role: claims["role"] || claims["http://schemas.xmlsoap.org/ws/2005/05/identity/claims/role"] || null
};
}
function scheduleRefresh() {
if (refreshTimer) clearTimeout(refreshTimer);
if (!expiresAt) return;
const delay = Math.max(new Date(expiresAt).getTime() - Date.now() - REFRESH_BUFFER_MS, 5000);
refreshTimer = setTimeout(refreshSilently, delay);
}
export function applySession(tokenPair) {
accessToken = tokenPair.accessToken;
expiresAt = tokenPair.expiresAt;
user = userFromAccessToken(tokenPair.accessToken);
mustChangePassword = !!tokenPair.mustChangePassword;
scheduleRefresh();
notify();
refreshPermissions();
}
// Rechte kommen nicht aus dem JWT (das trägt nur den Rollennamen), sondern aus /api/auth/me -
// läuft nach dem schnellen JWT-basierten Zwischenstand nach und broadcastet erneut, sobald da.
async function refreshPermissions() {
const tokenAtCallTime = accessToken;
if (!tokenAtCallTime) return;
const profile = await meRequest(tokenAtCallTime);
if (!profile.ok || accessToken !== tokenAtCallTime) return;
user = { username: profile.username, role: profile.role, permissions: profile.permissions };
notify();
}
export function clearSession() {
accessToken = null;
expiresAt = null;
user = null;
mustChangePassword = false;
if (refreshTimer) clearTimeout(refreshTimer);
refreshTimer = null;
notify();
}
// Proaktiver Timer kurz vor Ablauf UND Fallback bei 401 (z.B. nach langer Inaktivität) -
// schickt keinen Token mehr mit, die HttpOnly-Cookie übernimmt das.
export async function refreshSilently() {
const result = await refreshRequest();
if (!result.ok) {
clearSession();
return false;
}
applySession(result);
return true;
}
// Ersetzt main.cjs' bootstrapSession(): läuft einmal beim App-Start, damit ein Reload
// (der die Modul-Variable oben leert) die Session über die noch gültige HttpOnly-Cookie
// transparent wiederherstellt, statt den Nutzer erneut einloggen zu lassen.
export async function bootstrapSession() {
if (accessToken) return true;
return refreshSilently();
}
// Ruft callFn() auf und wiederholt einmal nach Silent-Refresh bei 401 - gemeinsame
// Grundlage für alle <resource>Api.js-Aufrufe und den generischen api:*-Proxy.
export async function withAuthRetry(callFn) {
if (!accessToken) return { ok: false, status: 401, data: null };
let result = await callFn();
if (result.status === 401 && (await refreshSilently())) {
result = await callFn();
}
return result;
}
+44
View File
@@ -0,0 +1,44 @@
import { TimeEntriesApi } from "omsorgcore-client-ts";
import { configFor, callApi } from "./apiClientHelpers.js";
// Kapselt /api/time-entries von omsorgCore (TimeEntriesController) über den generierten Client
// (omsorgcore-client-ts). Anlegen (mit Date-Feldern) passiert nur über omsorgWeb/mitarbeiter-app,
// omsorgapp braucht hier Lesen + Bearbeiten (solange status.isEditableByOwner) + Prüfungs-Entscheidungen.
// date muss als Date-Objekt übergeben werden - siehe absencesApi.js für dasselbe Muster.
function toDatePayload(payload) {
return {
...payload,
date: payload.date ? new Date(payload.date) : payload.date
};
}
export async function listTimeEntries(accessToken, { statusId, employeeId, orderId, page = 1, pageSize = 20 } = {}) {
const api = new TimeEntriesApi(configFor(accessToken));
return callApi(api.apiTimeEntriesGetRaw({ statusId, employeeId, orderId, page, pageSize }));
}
export async function getTimeEntry(accessToken, id) {
const api = new TimeEntriesApi(configFor(accessToken));
return callApi(api.apiTimeEntriesIdGetRaw({ id }));
}
export async function updateTimeEntry(accessToken, id, payload) {
const api = new TimeEntriesApi(configFor(accessToken));
return callApi(api.apiTimeEntriesIdPutRaw({ id, updateTimeEntryRequest: toDatePayload(payload) }));
}
export async function submitTimeEntry(accessToken, id) {
const api = new TimeEntriesApi(configFor(accessToken));
return callApi(api.apiTimeEntriesIdSubmitPostRaw({ id }));
}
export async function decideTimeEntry(accessToken, id, payload) {
const api = new TimeEntriesApi(configFor(accessToken));
return callApi(api.apiTimeEntriesIdDecisionPostRaw({ id, timeEntryDecisionRequest: payload }));
}
export async function deleteTimeEntry(accessToken, id) {
const api = new TimeEntriesApi(configFor(accessToken));
return callApi(api.apiTimeEntriesIdDeleteRaw({ id }));
}
+85
View File
@@ -0,0 +1,85 @@
import { TrashApi } from "omsorgcore-client-ts";
import { configFor, callApi } from "./apiClientHelpers.js";
// Kapselt /api/trash von omsorgCore (TrashController) über den generierten Client
// (omsorgcore-client-ts) - Papierkorb für die Objekte mit Soft-Delete.
export async function listDeletedEmployees(accessToken, search) {
const api = new TrashApi(configFor(accessToken));
return callApi(api.apiTrashEmployeesGetRaw({ search }));
}
export async function restoreEmployee(accessToken, id) {
const api = new TrashApi(configFor(accessToken));
return callApi(api.apiTrashEmployeesIdRestorePostRaw({ id }));
}
export async function listDeletedFacilities(accessToken, search) {
const api = new TrashApi(configFor(accessToken));
return callApi(api.apiTrashFacilitiesGetRaw({ search }));
}
export async function restoreFacility(accessToken, id) {
const api = new TrashApi(configFor(accessToken));
return callApi(api.apiTrashFacilitiesIdRestorePostRaw({ id }));
}
export async function listDeletedContracts(accessToken, search) {
const api = new TrashApi(configFor(accessToken));
return callApi(api.apiTrashContractsGetRaw({ search }));
}
export async function restoreContract(accessToken, id) {
const api = new TrashApi(configFor(accessToken));
return callApi(api.apiTrashContractsIdRestorePostRaw({ id }));
}
export async function listDeletedOrders(accessToken, search) {
const api = new TrashApi(configFor(accessToken));
return callApi(api.apiTrashOrdersGetRaw({ search }));
}
export async function restoreOrder(accessToken, id) {
const api = new TrashApi(configFor(accessToken));
return callApi(api.apiTrashOrdersIdRestorePostRaw({ id }));
}
export async function listDeletedFacilityContacts(accessToken, search) {
const api = new TrashApi(configFor(accessToken));
return callApi(api.apiTrashFacilityContactsGetRaw({ search }));
}
export async function restoreFacilityContact(accessToken, id) {
const api = new TrashApi(configFor(accessToken));
return callApi(api.apiTrashFacilityContactsIdRestorePostRaw({ id }));
}
export async function listDeletedFacilityQualificationRates(accessToken, search) {
const api = new TrashApi(configFor(accessToken));
return callApi(api.apiTrashFacilityQualificationRatesGetRaw({ search }));
}
export async function restoreFacilityQualificationRate(accessToken, id) {
const api = new TrashApi(configFor(accessToken));
return callApi(api.apiTrashFacilityQualificationRatesIdRestorePostRaw({ id }));
}
export async function listDeletedAbsences(accessToken, search) {
const api = new TrashApi(configFor(accessToken));
return callApi(api.apiTrashAbsencesGetRaw({ search }));
}
export async function restoreAbsence(accessToken, id) {
const api = new TrashApi(configFor(accessToken));
return callApi(api.apiTrashAbsencesIdRestorePostRaw({ id }));
}
export async function listDeletedTimeEntries(accessToken, search) {
const api = new TrashApi(configFor(accessToken));
return callApi(api.apiTrashTimeEntriesGetRaw({ search }));
}
export async function restoreTimeEntry(accessToken, id) {
const api = new TrashApi(configFor(accessToken));
return callApi(api.apiTrashTimeEntriesIdRestorePostRaw({ id }));
}
+39
View File
@@ -0,0 +1,39 @@
import { UsersApi } from "omsorgcore-client-ts";
import { configFor, callApi } from "./apiClientHelpers.js";
// Kapselt /api/users von omsorgCore (UsersController) über den generierten Client
// (omsorgcore-client-ts).
export async function listUsers(accessToken) {
const api = new UsersApi(configFor(accessToken));
return callApi(api.apiUsersGetRaw());
}
export async function createUser(accessToken, payload) {
const api = new UsersApi(configFor(accessToken));
return callApi(api.apiUsersPostRaw({ createUserRequest: payload }));
}
export async function listPermissionOverrides(accessToken, userId) {
const api = new UsersApi(configFor(accessToken));
return callApi(api.apiUsersIdPermissionOverridesGetRaw({ id: userId }));
}
export async function addPermissionOverride(accessToken, userId, payload) {
const api = new UsersApi(configFor(accessToken));
return callApi(api.apiUsersIdPermissionOverridesPostRaw({ id: userId, addUserPermissionOverrideRequest: payload }));
}
export async function deletePermissionOverride(accessToken, userId, overrideId) {
const api = new UsersApi(configFor(accessToken));
return callApi(api.apiUsersIdPermissionOverridesOverrideIdDeleteRaw({ id: userId, overrideId }));
}
export async function updateUser(accessToken, userId, payload) {
const api = new UsersApi(configFor(accessToken));
return callApi(api.apiUsersIdPutRaw({ id: userId, updateUserRequest: payload }));
}
export async function resetPassword(accessToken, userId, payload) {
const api = new UsersApi(configFor(accessToken));
return callApi(api.apiUsersIdResetPasswordPostRaw({ id: userId, resetUserPasswordRequest: payload }));
}
+45
View File
@@ -0,0 +1,45 @@
import { ValueListsApi } from "omsorgcore-client-ts";
import { configFor, callApi } from "./apiClientHelpers.js";
// Kapselt /api/value-lists von omsorgCore (ValueListsController) über den generierten
// Client (omsorgcore-client-ts) - konfigurierbare Auswahllisten (Mitarbeiterstatus,
// Beschäftigungsart, CRM-Status, Einrichtungstyp, Vertragstyp/-status, Auftragsstatus).
export async function listValueLists(accessToken) {
const api = new ValueListsApi(configFor(accessToken));
return callApi(api.apiValueListsGetRaw());
}
export async function listItems(accessToken, key) {
const api = new ValueListsApi(configFor(accessToken));
return callApi(api.apiValueListsKeyItemsGetRaw({ key }));
}
export async function createItem(accessToken, key, payload) {
const api = new ValueListsApi(configFor(accessToken));
return callApi(api.apiValueListsKeyItemsPostRaw({ key, createValueListItemRequest: payload }));
}
export async function updateItem(accessToken, key, id, payload) {
const api = new ValueListsApi(configFor(accessToken));
return callApi(api.apiValueListsKeyItemsIdPutRaw({ key, id, updateValueListItemRequest: payload }));
}
export async function deleteItem(accessToken, key, id) {
const api = new ValueListsApi(configFor(accessToken));
return callApi(api.apiValueListsKeyItemsIdDeleteRaw({ key, id }));
}
export async function getUsages(accessToken, key, id) {
const api = new ValueListsApi(configFor(accessToken));
return callApi(api.apiValueListsKeyItemsIdUsagesGetRaw({ key, id }));
}
export async function listTransitions(accessToken, key) {
const api = new ValueListsApi(configFor(accessToken));
return callApi(api.apiValueListsKeyTransitionsGetRaw({ key }));
}
export async function replaceTransitions(accessToken, key, payload) {
const api = new ValueListsApi(configFor(accessToken));
return callApi(api.apiValueListsKeyTransitionsPutRaw({ key, valueListTransitionRequest: payload }));
}
+10 -1
View File
@@ -77,6 +77,14 @@ export function AuthProvider({ children }) {
[user]
);
// Rein kosmetisch (z.B. Suchfeld ausblenden, wenn ohnehin nur der eigene Datensatz zurückkommt) -
// die eigentliche Durchsetzung von "nur eigene Daten" passiert serverseitig (siehe omsorgCore/
// CLAUDE.md, "Datenebenen-Scope"), nicht hier.
const getScope = useCallback(
(module, action) => user?.permissions?.find((p) => p.module === module && p.action === action)?.scope ?? null,
[user]
);
return (
<AuthContext.Provider
value={{
@@ -88,7 +96,8 @@ export function AuthProvider({ children }) {
login,
logout,
changePassword,
hasPermission
hasPermission,
getScope
}}
>
{children}
+12 -2
View File
@@ -3,6 +3,9 @@ import AppLayout from "../layouts/AppLayout";
import HomePage from "../modules/home/HomePage";
import EmployeesPage from "../modules/employees/EmployeesPage";
import FacilitiesPage from "../modules/facilities/FacilitiesPage";
import OrdersPage from "../modules/orders/OrdersPage";
import AbsencesPage from "../modules/absences/AbsencesPage";
import TimeEntriesPage from "../modules/timeEntries/TimeEntriesPage";
import LoginPage from "../modules/auth/LoginPage";
import ChangePasswordScreen from "../modules/auth/ChangePasswordScreen";
import ForgotPasswordUsernamePage from "../modules/auth/ForgotPasswordUsernamePage";
@@ -11,6 +14,7 @@ import ForgotPasswordNewPasswordPage from "../modules/auth/ForgotPasswordNewPass
import DebugSessionsPage from "../modules/debug/DebugSessionsPage";
import SettingsPage from "../modules/settings/SettingsPage";
import AuditLogPage from "../modules/auditLog/AuditLogPage";
import TrashPage from "../modules/trash/TrashPage";
import { useAuth } from "./AuthContext";
import { isNavItemVisible } from "./navPermissions";
function PlaceholderPage({ title }) {
@@ -77,11 +81,15 @@ export default function App() {
case "Home":
return <HomePage />;
case "Mitarbeiter":
return <EmployeesPage />;
return <EmployeesPage />;
case "Kunden":
return <FacilitiesPage />;
case "Disposition":
return <PlaceholderPage title="Disposition" />;
return <OrdersPage />;
case "Abwesenheiten":
return <AbsencesPage />;
case "Zeiterfassung":
return <TimeEntriesPage />;
case "Kalkulation":
return <PlaceholderPage title="Kalkulation" />;
case "Fahrzeuge":
@@ -96,6 +104,8 @@ export default function App() {
return <DebugSessionsPage />;
case "Audit-Log":
return <AuditLogPage />;
case "Papierkorb":
return <TrashPage />;
default:
return <HomePage />;
}
+26 -1
View File
@@ -4,14 +4,39 @@ export const NAV_MODULES = {
Mitarbeiter: ModuleType.Employees,
Kunden: ModuleType.Facilities,
Disposition: ModuleType.Orders,
Abwesenheiten: ModuleType.Absences,
Zeiterfassung: ModuleType.TimeEntries,
Rechnungen: ModuleType.Invoices,
Controlling: ModuleType.Controlling,
Einstellungen: ModuleType.UserManagement,
Debug: ModuleType.UserManagement,
"Audit-Log": ModuleType.AuditLog
};
// Papierkorb hat kein eigenes Modul-Recht - sichtbar, sobald irgendein "Recover"-Recht auf einem
// der Objekte mit Soft-Delete besteht (siehe TrashController in omsorgCore).
export const TRASH_MODULES = [
ModuleType.Employees,
ModuleType.Facilities,
ModuleType.Contracts,
ModuleType.Orders,
ModuleType.Absences,
ModuleType.TimeEntries
];
// Einstellungen bündelt drei unabhängige Rechte (siehe SettingsPage.jsx, die jeden Tab einzeln
// gegen sein eigenes Modul prüft) - der Sidebar-Tab selbst ist sichtbar, sobald irgendeines davon
// View gewährt, analog zum Papierkorb-Muster oben.
export const SETTINGS_MODULES = [ModuleType.Users, ModuleType.UserManagement, ModuleType.Configuration];
export function isNavItemVisible(label, hasPermission) {
if (label === "Papierkorb") {
return TRASH_MODULES.some((module) => hasPermission(module, "Recover"));
}
if (label === "Einstellungen") {
return SETTINGS_MODULES.some((module) => hasPermission(module, "View"));
}
const module = NAV_MODULES[label];
return !module || hasPermission(module, "View");
}
+56 -10
View File
@@ -4,23 +4,69 @@ import { useEffect, useState } from "react";
// Abschnitt "Konfigurierbare Auswahllisten") über window.omsorg.valueLists.listItems.
// Ersetzt die früher hier hartcodierten Options-Arrays (Mitarbeiterstatus,
// Beschäftigungsart, CRM-Status, Einrichtungstyp, ...).
//
// Modul-weiter Cache: dieselbe Liste wird oft von mehreren gleichzeitig
// gemounteten Komponenten gebraucht (z.B. EmployeesPage + EmployeeForm) und
// ändert sich fast nie (nur über die "Status-Verwaltung"). Ein kurzlebiger
// TTL-Cache erspart wiederholte Netzwerk-Roundtrips bei jedem Seitenaufruf,
// ohne echte Änderungen lange zu verzögern (siehe invalidateValueListCache).
const CACHE_TTL_MS = 60_000;
const cache = new Map(); // key -> { items, fetchedAt }
const inflight = new Map(); // key -> Promise<items>
async function fetchItems(key) {
let promise = inflight.get(key);
if (!promise) {
promise = window.omsorg.valueLists.listItems(key).then((result) => {
const items = result.ok ? result.data ?? [] : [];
cache.set(key, { items, fetchedAt: Date.now() });
return items;
}).finally(() => {
inflight.delete(key);
});
inflight.set(key, promise);
}
return promise;
}
function getCached(key) {
const entry = cache.get(key);
if (!entry) return undefined;
if (Date.now() - entry.fetchedAt > CACHE_TTL_MS) return undefined;
return entry.items;
}
export function invalidateValueListCache(key) {
if (key === undefined) {
cache.clear();
} else {
cache.delete(key);
}
}
export function useValueListItems(key) {
const [items, setItems] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const cached = getCached(key);
const [items, setItems] = useState(cached ?? []);
const [isLoading, setIsLoading] = useState(cached === undefined);
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);
}
const fresh = getCached(key);
if (fresh !== undefined) {
setItems(fresh);
setIsLoading(false);
return undefined;
}
load();
setIsLoading(true);
fetchItems(key).then((result) => {
if (!cancelled) {
setItems(result);
setIsLoading(false);
}
});
return () => {
cancelled = true;
};
+8 -2
View File
@@ -10,7 +10,10 @@ import {
BarChart3,
Settings,
Bug,
ScrollText
ScrollText,
Trash2,
CalendarOff,
Clock
} from "lucide-react";
import { useAuth } from "../app/AuthContext";
import { isNavItemVisible } from "../app/navPermissions";
@@ -20,13 +23,16 @@ const menu = [
{ icon: Users, label: "Mitarbeiter" },
{ icon: Building2, label: "Kunden" },
{ icon: CalendarDays, label: "Disposition" },
{ icon: CalendarOff, label: "Abwesenheiten" },
{ icon: Clock, label: "Zeiterfassung" },
{ icon: Calculator, label: "Kalkulation" },
{ icon: Car, label: "Fahrzeuge" },
{ icon: FileText, label: "Rechnungen" },
{ icon: BarChart3, label: "Controlling" },
{ icon: Settings, label: "Einstellungen" },
{ icon: Bug, label: "Debug" },
{ icon: ScrollText, label: "Audit-Log" }
{ icon: ScrollText, label: "Audit-Log" },
{ icon: Trash2, label: "Papierkorb" }
];
export default function Sidebar({
+5
View File
@@ -2,8 +2,13 @@ import React from "react";
import { createRoot } from "react-dom/client";
import App from "./app/app";
import { AuthProvider } from "./app/AuthContext";
import { buildOmsorgApi } from "./api/index.js";
import "./style.css";
// Ersetzt electron/preload.cjs' contextBridge.exposeInMainWorld("omsorg", ...) - AuthContext.jsx
// und alle src/modules/**-Aufrufstellen greifen unverändert auf window.omsorg zu.
window.omsorg = buildOmsorgApi();
createRoot(document.getElementById("root")).render(
<React.StrictMode>
<AuthProvider>
@@ -0,0 +1,151 @@
import { useState } from "react";
import { Check, Pencil, X } from "lucide-react";
import OmsorgCard from "../../components/OmsorgCard";
import OmsorgButton from "../../components/ui/OmsorgButton";
import { useAuth } from "../../app/AuthContext";
import { useValueListItems } from "../../app/useValueListItems";
import EditAbsenceDialog from "./EditAbsenceDialog";
function formatDate(value) {
if (!value) {
return "—";
}
return new Date(value).toLocaleDateString("de-DE");
}
export default function AbsenceDetailPanel({ absence, onChanged }) {
const { hasPermission } = useAuth();
const canDecide = hasPermission("Absences", "Approve");
const canEdit = hasPermission("Absences", "Edit");
const [adminNote, setAdminNote] = useState("");
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState(null);
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const { items: statusItems } = useValueListItems("AbsenceStatus");
if (!absence) {
return (
<OmsorgCard>
<div className="employee-detail-empty">
<h2>Kein Antrag ausgewählt</h2>
<p>Wähle links einen Antrag aus.</p>
</div>
</OmsorgCard>
);
}
// Nicht auf den Anzeigetext "Eingereicht" hartkodieren - der ist über die Status-Verwaltung
// umbenennbar (siehe omsorgCore/CLAUDE.md, AbsenceService.GetInitialStatusValueAsync). Solange
// die Liste noch lädt, gilt "nicht pending" (keine Aktionen anzeigen statt falsch-positiv).
const initialStatus = statusItems.find((item) => item.isInitial)?.value;
const isPending = absence.status === initialStatus;
async function handleDecision(status) {
setIsSaving(true);
setError(null);
const result = await window.omsorg.absences.decide(absence.id, { status, adminNote: adminNote.trim() || null });
setIsSaving(false);
if (!result.ok) {
setError(result.status === 403 ? "Keine Berechtigung, diesen Antrag zu entscheiden." : "Entscheidung konnte nicht gespeichert werden.");
return;
}
setAdminNote("");
onChanged?.(result.data);
}
return (
<OmsorgCard>
<div className="employee-detail">
<div className="employee-detail-header">
<div>
<h2>{absence.employeeName}</h2>
<span className="omsorg-badge omsorg-badge--neutral">{absence.status}</span>
</div>
{canEdit && isPending && (
<OmsorgButton variant="secondary" icon={Pencil} onClick={() => setIsEditDialogOpen(true)}>
Bearbeiten
</OmsorgButton>
)}
</div>
<div className="employee-detail-grid">
<div>
<strong>Art</strong>
<p>{absence.type}</p>
</div>
<div>
<strong>Zeitraum</strong>
<p>
{formatDate(absence.startDate)} {formatDate(absence.endDate)}
</p>
</div>
<div>
<strong>Grund</strong>
<p>{absence.reason ?? "—"}</p>
</div>
<div>
<strong>Vertretung</strong>
<p>{absence.substitute ?? "—"}</p>
</div>
<div>
<strong>Nachricht</strong>
<p>{absence.note ?? "—"}</p>
</div>
<div>
<strong>Kommentar</strong>
<p>{absence.adminNote ?? "—"}</p>
</div>
</div>
{canDecide && (
<div className="form-grid">
{!isPending && (
<p style={{ color: "var(--omsorg-text-secondary)" }}>
Bereits entschieden ({absence.status}) hier lässt sich die Entscheidung bei Bedarf noch ändern.
</p>
)}
<label className="form-field">
<span>Kommentar (optional)</span>
<textarea value={adminNote} onChange={(event) => setAdminNote(event.target.value)} maxLength={500} />
</label>
{error && <p className="login-error form-error">{error}</p>}
<div className="form-actions">
<OmsorgButton variant="secondary" icon={X} disabled={isSaving} onClick={() => handleDecision("Abgelehnt")}>
Ablehnen
</OmsorgButton>
<OmsorgButton icon={Check} disabled={isSaving} onClick={() => handleDecision("Genehmigt")}>
Genehmigen
</OmsorgButton>
</div>
</div>
)}
</div>
{isEditDialogOpen && (
<EditAbsenceDialog
absence={absence}
onClose={() => setIsEditDialogOpen(false)}
onUpdated={(updatedAbsence) => {
setIsEditDialogOpen(false);
onChanged?.(updatedAbsence);
}}
/>
)}
</OmsorgCard>
);
}
@@ -0,0 +1,105 @@
import OmsorgButton from "../../components/ui/OmsorgButton";
import { useValueListItems } from "../../app/useValueListItems";
export const emptyAbsenceForm = {
type: "",
startDate: "",
endDate: "",
reason: "",
substitute: "",
note: "",
};
function toDateInputValue(value) {
if (!value) {
return "";
}
const date = value instanceof Date ? value : new Date(value);
return Number.isNaN(date.getTime()) ? "" : date.toISOString().slice(0, 10);
}
export function absenceToFormValues(absence) {
return {
type: absence.type ?? "",
startDate: toDateInputValue(absence.startDate),
endDate: toDateInputValue(absence.endDate),
reason: absence.reason ?? "",
substitute: absence.substitute ?? "",
note: absence.note ?? "",
};
}
export function absenceFormToPayload(form) {
return {
type: form.type,
startDate: form.startDate,
endDate: form.endDate,
reason: form.reason.trim() || null,
substitute: form.substitute.trim() || null,
note: form.note.trim() || null,
};
}
export default function AbsenceForm({ form, onChange }) {
const { items: typeItems } = useValueListItems("AbsenceType");
function updateField(field) {
return (event) => onChange({ ...form, [field]: event.target.value });
}
return (
<div className="form-grid">
<label className="form-field">
<span>Art *</span>
<select value={form.type} onChange={updateField("type")} required>
<option value=""> auswählen </option>
{typeItems.map((option) => (
<option key={option.id} value={option.value}>
{option.value}
</option>
))}
</select>
</label>
<div className="form-row">
<label className="form-field">
<span>Beginn *</span>
<input type="date" value={form.startDate} onChange={updateField("startDate")} required />
</label>
<label className="form-field">
<span>Ende *</span>
<input type="date" value={form.endDate} onChange={updateField("endDate")} required />
</label>
</div>
<label className="form-field">
<span>Grund (optional)</span>
<input type="text" value={form.reason} onChange={updateField("reason")} maxLength={500} />
</label>
<label className="form-field">
<span>Vertretung (optional)</span>
<input type="text" value={form.substitute} onChange={updateField("substitute")} maxLength={200} />
</label>
<label className="form-field">
<span>Nachricht (optional)</span>
<textarea value={form.note} onChange={updateField("note")} maxLength={500} />
</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,184 @@
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";
import { useValueListItems } from "../../app/useValueListItems";
import AbsenceDetailPanel from "./AbsenceDetailPanel";
const PAGE_SIZE = 20;
export default function AbsencesPage() {
const { hasPermission } = useAuth();
const { items: statusOptions } = useValueListItems("AbsenceStatus");
const { items: typeOptions } = useValueListItems("AbsenceType");
const [absences, setAbsences] = useState([]);
const [totalCount, setTotalCount] = useState(0);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
const [selectedAbsenceId, setSelectedAbsenceId] = useState(null);
const [statusFilter, setStatusFilter] = useState("");
const [typeFilter, setTypeFilter] = useState("");
const [isFilterOpen, setIsFilterOpen] = useState(false);
const [page, setPage] = useState(1);
useEffect(() => {
setPage(1);
}, [statusFilter, typeFilter]);
const loadAbsences = useCallback(async () => {
setIsLoading(true);
setError(null);
const result = await window.omsorg.absences.list({
status: statusFilter || undefined,
type: typeFilter || undefined,
page,
pageSize: PAGE_SIZE,
});
if (result.ok) {
const data = result.data ?? { items: [], totalCount: 0 };
setAbsences(data.items ?? []);
setTotalCount(data.totalCount ?? 0);
setSelectedAbsenceId((current) =>
(data.items ?? []).some((absence) => absence.id === current) ? current : data.items?.[0]?.id ?? null
);
} else {
setError("Anträge konnten nicht geladen werden.");
}
setIsLoading(false);
}, [statusFilter, typeFilter, page]);
useEffect(() => {
loadAbsences();
}, [loadAbsences]);
const selectedAbsence = absences.find((absence) => absence.id === selectedAbsenceId) ?? null;
const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE));
function handleDecided() {
loadAbsences();
}
if (!hasPermission("Absences", "View")) {
return (
<OmsorgCard title="Abwesenheiten">
<p>Keine Berechtigung, Abwesenheitsanträge einzusehen.</p>
</OmsorgCard>
);
}
return (
<div className="employees-page">
<div className="page-heading">
<div>
<p className="page-eyebrow">Personal</p>
<h1>Abwesenheiten</h1>
<p className="page-description">
Urlaubs-, Krankmeldungs- und sonstige Abwesenheitsanträge des Außendienstes prüfen und entscheiden.
</p>
</div>
</div>
<OmsorgCard>
<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>Status</span>
<select value={statusFilter} onChange={(event) => setStatusFilter(event.target.value)}>
<option value="">Alle</option>
{statusOptions.map((option) => (
<option key={option.id} value={option.value}>
{option.value}
</option>
))}
</select>
</label>
<label className="form-field">
<span>Art</span>
<select value={typeFilter} onChange={(event) => setTypeFilter(event.target.value)}>
<option value="">Alle</option>
{typeOptions.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">
{absences.map((absence) => {
const isSelected = absence.id === selectedAbsenceId;
return (
<button
key={absence.id}
type="button"
className={
isSelected ? "employee-list-button employee-list-button--active" : "employee-list-button"
}
onClick={() => setSelectedAbsenceId(absence.id)}
>
<OmsorgCard>
<div className="employee-row">
<div className="employee-main">
<strong>{absence.employeeName}</strong>
<span> {absence.type}</span>
</div>
<div className="employee-status">
<span className="omsorg-badge omsorg-badge--neutral">{absence.status}</span>
</div>
</div>
</OmsorgCard>
</button>
);
})}
{absences.length === 0 && (
<OmsorgCard>
<div className="employees-empty-state">
<strong>Keinen Antrag gefunden</strong>
<span>Prüfe die eingestellten Filter.</span>
</div>
</OmsorgCard>
)}
{absences.length > 0 && (
<OmsorgPagination page={page} totalPages={totalPages} onPageChange={setPage} />
)}
</div>
<AbsenceDetailPanel absence={selectedAbsence} onChanged={handleDecided} />
</div>
)}
</div>
);
}
@@ -0,0 +1,66 @@
import { useState } from "react";
import AbsenceForm, { absenceFormToPayload, absenceToFormValues, FormActions } from "./AbsenceForm";
import ModalPortal from "../../components/ui/ModalPortal";
function errorMessage(result) {
if (result.status === 403) {
return "Keine Berechtigung, diesen Antrag zu bearbeiten.";
}
if (result.status === 400) {
return typeof result.data === "string" ? result.data : "Eingaben prüfen.";
}
return "Antrag konnte nicht gespeichert werden.";
}
export default function EditAbsenceDialog({ absence, onClose, onUpdated }) {
const [form, setForm] = useState(() => absenceToFormValues(absence));
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState(null);
async function handleSubmit(event) {
event.preventDefault();
if (!form.type || !form.startDate || !form.endDate) {
setError("Art, Beginn und Ende sind erforderlich.");
return;
}
if (form.endDate < form.startDate) {
setError("Das Ende darf nicht vor dem Beginn liegen.");
return;
}
setIsSaving(true);
setError(null);
const result = await window.omsorg.absences.update(absence.id, absenceFormToPayload(form));
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="Antrag bearbeiten">
<div className="modal-panel">
<h2>Antrag bearbeiten</h2>
<form onSubmit={handleSubmit}>
<AbsenceForm form={form} onChange={setForm} />
{error && <p className="login-error form-error">{error}</p>}
<FormActions onCancel={onClose} isSaving={isSaving} submitLabel="Speichern" />
</form>
</div>
</div>
</ModalPortal>
);
}
@@ -33,7 +33,7 @@ function categoryLabel(category) {
// Bearbeiten-/Löschen-Aktionen (siehe omsorgCore/CLAUDE.md, Abschnitt "Audit-Log").
export default function AuditLogPage() {
const { hasPermission } = useAuth();
const canViewActor = hasPermission("UserManagement", "View");
const canViewActor = hasPermission("Users", "View");
const [entries, setEntries] = useState([]);
const [totalCount, setTotalCount] = useState(0);
@@ -24,6 +24,8 @@ export default function ForgotPasswordUsernamePage({ onNext, onBackToLogin }) {
if (result.status === "sent") {
onNext(username);
} else if (result.status === "email_unavailable") {
setError("Der E-Mail-Versand ist gerade nicht verfügbar. Bitte später erneut versuchen oder einen Administrator kontaktieren.");
} else {
setCannotReset(true);
}
@@ -68,7 +68,12 @@ export default function DebugSessionsPage() {
setTestEmailResult(
result.ok
? { ok: true, message: `Testmail an ${testEmailAddress} ausgelöst.` }
: { ok: false, message: "Testmail konnte nicht versendet werden." }
: {
ok: false,
message: result.data?.message
? `Testmail konnte nicht versendet werden: ${result.data.message}`
: "Testmail konnte nicht versendet werden.",
}
);
setIsSendingTestEmail(false);
}
@@ -0,0 +1,159 @@
import OmsorgButton from "../../components/ui/OmsorgButton";
import { useValueListItems } from "../../app/useValueListItems";
export const emptyContractForm = {
contractType: "",
startDate: "",
endDate: "",
status: "",
weeklyHours: "",
hourlyWage: "",
allowancesDescription: "",
overtimeRules: "",
vacationDaysPerYear: "",
probationPeriodMonths: "",
};
export function contractToFormValues(contract) {
return {
contractType: contract.contractType ?? "",
startDate: contract.startDate ?? "",
endDate: contract.endDate ?? "",
status: contract.status ?? "",
weeklyHours: contract.weeklyHours ?? "",
hourlyWage: contract.hourlyWage ?? "",
allowancesDescription: contract.allowancesDescription ?? "",
overtimeRules: contract.overtimeRules ?? "",
vacationDaysPerYear: contract.vacationDaysPerYear ?? "",
probationPeriodMonths: contract.probationPeriodMonths ?? "",
};
}
export function contractFormToPayload(form, { employeeId, includeStatus = false } = {}) {
const payload = {
contractType: form.contractType,
employeeId,
facilityId: null,
startDate: form.startDate,
endDate: form.endDate || null,
weeklyHours: form.weeklyHours === "" ? null : Number(form.weeklyHours),
hourlyWage: form.hourlyWage === "" ? null : Number(form.hourlyWage),
allowancesDescription: form.allowancesDescription.trim() || null,
overtimeRules: form.overtimeRules.trim() || null,
vacationDaysPerYear: form.vacationDaysPerYear === "" ? null : Number(form.vacationDaysPerYear),
probationPeriodMonths: form.probationPeriodMonths === "" ? null : Number(form.probationPeriodMonths),
};
if (includeStatus) {
payload.status = form.status;
}
return payload;
}
export default function ContractForm({ form, onChange, includeStatus = false }) {
const { items: contractTypes } = useValueListItems("ContractType");
const { items: contractStatuses } = useValueListItems("ContractStatus");
function updateField(field) {
return (event) => onChange({ ...form, [field]: event.target.value });
}
return (
<div className="form-grid">
<label className="form-field">
<span>Vertragstyp *</span>
<select value={form.contractType} onChange={updateField("contractType")} required>
<option value=""> auswählen </option>
{contractTypes.map((option) => (
<option key={option.id} value={option.value}>
{option.value}
</option>
))}
</select>
</label>
{includeStatus && (
<label className="form-field">
<span>Status</span>
<select value={form.status} onChange={updateField("status")}>
{contractStatuses.map((option) => (
<option key={option.id} value={option.value}>
{option.value}
</option>
))}
</select>
</label>
)}
<div className="form-row">
<label className="form-field">
<span>Beginn *</span>
<input type="date" value={form.startDate} onChange={updateField("startDate")} required />
</label>
<label className="form-field">
<span>Ende</span>
<input type="date" value={form.endDate} onChange={updateField("endDate")} />
</label>
</div>
<div className="form-row">
<label className="form-field">
<span>Arbeitszeit (Std./Woche)</span>
<input type="number" min="0" step="0.5" value={form.weeklyHours} onChange={updateField("weeklyHours")} />
</label>
<label className="form-field">
<span>Stundenlohn ()</span>
<input type="number" min="0" step="0.01" value={form.hourlyWage} onChange={updateField("hourlyWage")} />
</label>
</div>
<label className="form-field">
<span>Zuschläge</span>
<input value={form.allowancesDescription} onChange={updateField("allowancesDescription")} />
</label>
<label className="form-field">
<span>Überstundenregelung</span>
<input value={form.overtimeRules} onChange={updateField("overtimeRules")} />
</label>
<div className="form-row">
<label className="form-field">
<span>Urlaubsanspruch (Tage/Jahr)</span>
<input
type="number"
min="0"
value={form.vacationDaysPerYear}
onChange={updateField("vacationDaysPerYear")}
/>
</label>
<label className="form-field">
<span>Probezeit (Monate)</span>
<input
type="number"
min="0"
value={form.probationPeriodMonths}
onChange={updateField("probationPeriodMonths")}
/>
</label>
</div>
</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,154 @@
import { useCallback, useEffect, useState } from "react";
import { Pencil, Plus, Trash2 } from "lucide-react";
import OmsorgButton from "../../components/ui/OmsorgButton";
import { useAuth } from "../../app/AuthContext";
import CreateContractDialog from "./CreateContractDialog";
import EditContractDialog from "./EditContractDialog";
function formatDate(value) {
if (!value) return "—";
return new Date(value).toLocaleDateString("de-DE");
}
function formatPeriod(contract) {
return `${formatDate(contract.startDate)} ${contract.endDate ? formatDate(contract.endDate) : "unbefristet"}`;
}
export default function ContractsList({ employeeId }) {
const { hasPermission } = useAuth();
const canCreate = hasPermission("Contracts", "Create");
const canEdit = hasPermission("Contracts", "Edit");
const canDelete = hasPermission("Contracts", "Delete");
const [contracts, setContracts] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [editingContract, setEditingContract] = useState(null);
const [deletingContractId, setDeletingContractId] = useState(null);
const loadContracts = useCallback(async () => {
setIsLoading(true);
setError(null);
const result = await window.omsorg.contracts.list({ employeeId, pageSize: 100 });
if (result.ok) {
setContracts(result.data?.items ?? []);
} else {
setError("Verträge konnten nicht geladen werden.");
}
setIsLoading(false);
}, [employeeId]);
useEffect(() => {
loadContracts();
}, [loadContracts]);
function handleCreated() {
setIsDialogOpen(false);
loadContracts();
}
function handleUpdated() {
setEditingContract(null);
loadContracts();
}
async function handleDelete(contract) {
if (!window.confirm(`Vertrag "${contract.contractType}" wirklich löschen?`)) {
return;
}
setDeletingContractId(contract.id);
const result = await window.omsorg.contracts.delete(contract.id);
setDeletingContractId(null);
if (result.ok) {
loadContracts();
}
}
return (
<div className="employee-tab-content">
<div className="page-heading">
<h3>Verträge</h3>
{canCreate && (
<OmsorgButton icon={Plus} variant="secondary" onClick={() => setIsDialogOpen(true)}>
Neuer Vertrag
</OmsorgButton>
)}
</div>
{error && <p className="login-error">{error}</p>}
{isLoading && <p>Lädt...</p>}
{!isLoading && contracts.length === 0 && <p>Noch kein Vertrag erfasst.</p>}
{!isLoading && contracts.length > 0 && (
<div className="facility-contacts-list">
{contracts.map((contract) => (
<div key={contract.id} className="facility-contact-row">
<div>
<strong>{contract.contractType}</strong>
<span className="omsorg-badge omsorg-badge--neutral"> {contract.status}</span>
<p>{formatPeriod(contract)}</p>
<p>
{[
contract.weeklyHours != null ? `${contract.weeklyHours} Std./Woche` : null,
contract.hourlyWage != null ? `${contract.hourlyWage} €/Std.` : null,
]
.filter(Boolean)
.join(" · ") || "—"}
</p>
</div>
<div>
{canEdit && (
<OmsorgButton
variant="secondary"
icon={Pencil}
onClick={() => setEditingContract(contract)}
>
Bearbeiten
</OmsorgButton>
)}
{canDelete && (
<OmsorgButton
variant="secondary"
icon={Trash2}
onClick={() => handleDelete(contract)}
disabled={deletingContractId === contract.id}
>
Löschen
</OmsorgButton>
)}
</div>
</div>
))}
</div>
)}
{isDialogOpen && (
<CreateContractDialog
employeeId={employeeId}
onClose={() => setIsDialogOpen(false)}
onCreated={handleCreated}
/>
)}
{editingContract && (
<EditContractDialog
employeeId={employeeId}
contract={editingContract}
onClose={() => setEditingContract(null)}
onUpdated={handleUpdated}
/>
)}
</div>
);
}
@@ -0,0 +1,66 @@
import { useState } from "react";
import ContractForm, { emptyContractForm, contractFormToPayload, FormActions } from "./ContractForm";
import ModalPortal from "../../components/ui/ModalPortal";
function errorMessage(result) {
if (result.status === 403) {
return "Keine Berechtigung, Verträge anzulegen.";
}
if (result.status === 400) {
return typeof result.data === "string" ? result.data : "Eingaben prüfen.";
}
return "Vertrag konnte nicht angelegt werden.";
}
export default function CreateContractDialog({ employeeId, onClose, onCreated }) {
const [form, setForm] = useState(emptyContractForm);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState(null);
async function handleSubmit(event) {
event.preventDefault();
if (!form.contractType) {
setError("Vertragstyp ist erforderlich.");
return;
}
if (!form.startDate) {
setError("Beginn ist erforderlich.");
return;
}
setIsSaving(true);
setError(null);
const result = await window.omsorg.contracts.create(contractFormToPayload(form, { employeeId }));
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 Vertrag">
<div className="modal-panel">
<h2>Neuer Vertrag</h2>
<form onSubmit={handleSubmit}>
<ContractForm 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,79 @@
import { useEffect, useState } from "react";
import { X } from "lucide-react";
import ModalPortal from "../../components/ui/ModalPortal";
export default function DocumentViewerDialog({ doc, onClose }) {
const [objectUrl, setObjectUrl] = useState(null);
const [error, setError] = useState(null);
useEffect(() => {
let url = null;
let cancelled = false;
async function load() {
try {
const result = await window.omsorg.documents.view(doc.id);
if (cancelled) return;
if (!result.ok) {
setError("Dokument konnte nicht geladen werden.");
return;
}
url = URL.createObjectURL(result.data.blob);
setObjectUrl(url);
} catch (err) {
if (!cancelled) {
setError(`Dokument konnte nicht geladen werden: ${err.message}`);
}
}
}
load();
return () => {
cancelled = true;
if (url) {
URL.revokeObjectURL(url);
}
};
}, [doc.id, doc.contentType]);
function renderContent() {
if (error) {
return <p className="login-error">{error}</p>;
}
if (!objectUrl) {
return <p>Lädt...</p>;
}
if (doc.contentType === "application/pdf") {
return <iframe src={objectUrl} title={doc.fileName} className="document-viewer-content" />;
}
if (doc.contentType?.startsWith("image/")) {
return <img src={objectUrl} alt={doc.fileName} className="document-viewer-content document-viewer-content--image" />;
}
return <p>Keine Vorschau verfügbar, bitte herunterladen.</p>;
}
return (
<ModalPortal>
<div className="modal-overlay" role="dialog" aria-modal="true" aria-label={`Vorschau: ${doc.fileName}`}>
<div className="modal-panel modal-panel--viewer">
<button type="button" className="modal-close-button" onClick={onClose} aria-label="Schließen">
<X size={20} />
</button>
<h2>{doc.fileName}</h2>
<div className="document-viewer-body">{renderContent()}</div>
</div>
</div>
</ModalPortal>
);
}
@@ -0,0 +1,205 @@
import { useCallback, useEffect, useState } from "react";
import { Download, Eye, Pencil, Plus, Trash2 } from "lucide-react";
import OmsorgButton from "../../components/ui/OmsorgButton";
import { useAuth } from "../../app/AuthContext";
import UploadDocumentDialog from "./UploadDocumentDialog";
import EditDocumentDialog from "./EditDocumentDialog";
import DocumentViewerDialog from "./DocumentViewerDialog";
function formatDate(value) {
if (!value) return "—";
return new Date(value).toLocaleDateString("de-DE");
}
function formatSize(bytes) {
if (bytes == null) return "—";
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function groupByCategory(documents) {
const groups = new Map();
for (const doc of documents) {
const key = doc.category ?? "Sonstiges";
if (!groups.has(key)) {
groups.set(key, []);
}
groups.get(key).push(doc);
}
return groups;
}
export default function DocumentsList({ employeeId }) {
const { hasPermission } = useAuth();
const canCreate = hasPermission("Documents", "Create");
const canView = hasPermission("Documents", "View");
const canEdit = hasPermission("Documents", "Edit");
const canDelete = hasPermission("Documents", "Delete");
const [documents, setDocuments] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [editingDocument, setEditingDocument] = useState(null);
const [viewingDocument, setViewingDocument] = useState(null);
const [busyDocumentId, setBusyDocumentId] = useState(null);
const loadDocuments = useCallback(async () => {
setIsLoading(true);
setError(null);
const result = await window.omsorg.documents.list({ entityType: "Employee", entityId: employeeId });
if (result.ok) {
setDocuments(result.data ?? []);
} else {
setError("Dokumente konnten nicht geladen werden.");
}
setIsLoading(false);
}, [employeeId]);
useEffect(() => {
loadDocuments();
}, [loadDocuments]);
function handleUploaded() {
setIsDialogOpen(false);
loadDocuments();
}
function handleUpdated() {
setEditingDocument(null);
loadDocuments();
}
async function handleDownload(doc) {
setBusyDocumentId(doc.id);
await window.omsorg.documents.download(doc.id, doc.fileName);
setBusyDocumentId(null);
}
async function handleDelete(doc) {
if (!window.confirm(`Dokument "${doc.fileName}" wirklich löschen?`)) {
return;
}
setBusyDocumentId(doc.id);
const result = await window.omsorg.documents.delete(doc.id);
setBusyDocumentId(null);
if (result.ok) {
loadDocuments();
}
}
const groups = groupByCategory(documents);
return (
<div className="employee-tab-content">
<div className="page-heading">
<h3>Dokumente</h3>
{canCreate && (
<OmsorgButton icon={Plus} variant="secondary" onClick={() => setIsDialogOpen(true)}>
Hochladen
</OmsorgButton>
)}
</div>
{error && <p className="login-error">{error}</p>}
{isLoading && <p>Lädt...</p>}
{!isLoading && documents.length === 0 && <p>Noch kein Dokument hochgeladen.</p>}
{!isLoading &&
Array.from(groups.entries()).map(([category, categoryDocuments]) => (
<div key={category}>
<h4>{category}</h4>
<div className="facility-contacts-list">
{categoryDocuments.map((doc) => (
<div key={doc.id} className="facility-contact-row">
<div>
<strong>{doc.fileName}</strong>
<p>{doc.description || "—"}</p>
<p>
{formatSize(doc.sizeBytes)} · {doc.uploadedByUsername ?? "—"} ·{" "}
{formatDate(doc.createdAt)}
</p>
</div>
<div>
{canView && (
<OmsorgButton
variant="secondary"
icon={Eye}
onClick={() => setViewingDocument(doc)}
disabled={busyDocumentId === doc.id}
>
Anzeigen
</OmsorgButton>
)}
{canView && (
<OmsorgButton
variant="secondary"
icon={Download}
onClick={() => handleDownload(doc)}
disabled={busyDocumentId === doc.id}
>
Herunterladen
</OmsorgButton>
)}
{canEdit && (
<OmsorgButton
variant="secondary"
icon={Pencil}
onClick={() => setEditingDocument(doc)}
disabled={busyDocumentId === doc.id}
>
Bearbeiten
</OmsorgButton>
)}
{canDelete && (
<OmsorgButton
variant="secondary"
icon={Trash2}
onClick={() => handleDelete(doc)}
disabled={busyDocumentId === doc.id}
>
Löschen
</OmsorgButton>
)}
</div>
</div>
))}
</div>
</div>
))}
{isDialogOpen && (
<UploadDocumentDialog
employeeId={employeeId}
onClose={() => setIsDialogOpen(false)}
onUploaded={handleUploaded}
/>
)}
{editingDocument && (
<EditDocumentDialog
doc={editingDocument}
onClose={() => setEditingDocument(null)}
onUpdated={handleUpdated}
/>
)}
{viewingDocument && (
<DocumentViewerDialog doc={viewingDocument} onClose={() => setViewingDocument(null)} />
)}
</div>
);
}
@@ -0,0 +1,67 @@
import { useState } from "react";
import ContractForm, { contractFormToPayload, contractToFormValues, FormActions } from "./ContractForm";
import ModalPortal from "../../components/ui/ModalPortal";
function errorMessage(result) {
if (result.status === 403) {
return "Keine Berechtigung, Verträge zu bearbeiten.";
}
if (result.status === 400) {
return typeof result.data === "string" ? result.data : "Eingaben prüfen.";
}
return "Vertrag konnte nicht gespeichert werden.";
}
export default function EditContractDialog({ employeeId, contract, onClose, onUpdated }) {
const [form, setForm] = useState(() => contractToFormValues(contract));
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState(null);
async function handleSubmit(event) {
event.preventDefault();
if (!form.contractType) {
setError("Vertragstyp ist erforderlich.");
return;
}
if (!form.startDate) {
setError("Beginn ist erforderlich.");
return;
}
setIsSaving(true);
setError(null);
const payload = contractFormToPayload(form, { employeeId, includeStatus: true });
const result = await window.omsorg.contracts.update(contract.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="Vertrag bearbeiten">
<div className="modal-panel">
<h2>Vertrag bearbeiten</h2>
<form onSubmit={handleSubmit}>
<ContractForm form={form} onChange={setForm} includeStatus />
{error && <p className="login-error form-error">{error}</p>}
<FormActions onCancel={onClose} isSaving={isSaving} submitLabel="Speichern" />
</form>
</div>
</div>
</ModalPortal>
);
}
@@ -0,0 +1,96 @@
import { useState } from "react";
import ModalPortal from "../../components/ui/ModalPortal";
import { useValueListItems } from "../../app/useValueListItems";
import { FormActions } from "./ContractForm";
function errorMessage(result) {
if (result.status === 403) {
return "Keine Berechtigung, Dokumente zu bearbeiten.";
}
if (result.status === 400) {
return typeof result.data === "string" ? result.data : "Eingaben prüfen.";
}
return "Dokument konnte nicht gespeichert werden.";
}
export default function EditDocumentDialog({ doc, onClose, onUpdated }) {
const { items: categories } = useValueListItems("DocumentCategory");
const [category, setCategory] = useState(doc.category ?? "");
const [description, setDescription] = useState(doc.description ?? "");
const [fileName, setFileName] = useState(doc.fileName ?? "");
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState(null);
async function handleSubmit(event) {
event.preventDefault();
if (!category) {
setError("Kategorie ist erforderlich.");
return;
}
if (!fileName.trim()) {
setError("Dateiname ist erforderlich.");
return;
}
setIsSaving(true);
setError(null);
const result = await window.omsorg.documents.update(doc.id, {
category,
description: description.trim() || null,
fileName: fileName.trim(),
});
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="Dokument bearbeiten">
<div className="modal-panel">
<h2>Dokument bearbeiten</h2>
<form onSubmit={handleSubmit}>
<div className="form-grid">
<label className="form-field">
<span>Kategorie *</span>
<select value={category} onChange={(event) => setCategory(event.target.value)} required>
<option value=""> auswählen </option>
{categories.map((option) => (
<option key={option.id} value={option.value}>
{option.value}
</option>
))}
</select>
</label>
<label className="form-field">
<span>Dateiname *</span>
<input value={fileName} onChange={(event) => setFileName(event.target.value)} required />
</label>
<label className="form-field">
<span>Beschreibung</span>
<textarea value={description} onChange={(event) => setDescription(event.target.value)} />
</label>
</div>
{error && <p className="login-error form-error">{error}</p>}
<FormActions onCancel={onClose} isSaving={isSaving} submitLabel="Speichern" />
</form>
</div>
</div>
</ModalPortal>
);
}
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react";
import { Pencil } from "lucide-react";
import { Pencil, Trash2 } from "lucide-react";
import OmsorgCard from "../../components/OmsorgCard";
import OmsorgBadge from "../../components/ui/OmsorgBadge";
@@ -7,6 +7,8 @@ import OmsorgButton from "../../components/ui/OmsorgButton";
import { useAuth } from "../../app/AuthContext";
import EmployeeTabs from "./EmployeeTabs";
import EditEmployeeDialog from "./EditEmployeeDialog";
import ContractsList from "./ContractsList";
import DocumentsList from "./DocumentsList";
function getInitials(firstName = "", lastName = "") {
return `${firstName[0] ?? ""}${lastName[0] ?? ""}`.toUpperCase();
@@ -31,13 +33,30 @@ function formatAddress(employee) {
export default function EmployeeDetailPanel({ employee, onUpdated }) {
const { hasPermission } = useAuth();
const canEdit = hasPermission("Employees", "Edit");
const canDelete = hasPermission("Employees", "Delete");
const canViewDocuments = hasPermission("Documents", "View");
const [activeTab, setActiveTab] = useState("overview");
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
useEffect(() => {
setActiveTab("overview");
}, [employee?.id]);
async function handleDelete() {
if (!window.confirm(`${employee.firstName} ${employee.lastName} wirklich löschen?`)) {
return;
}
setIsDeleting(true);
const result = await window.omsorg.employees.delete(employee.id);
setIsDeleting(false);
if (result.ok) {
onUpdated?.(null);
}
}
if (!employee) {
return (
<OmsorgCard>
@@ -52,37 +71,10 @@ export default function EmployeeDetailPanel({ employee, onUpdated }) {
const renderTabContent = () => {
switch (activeTab) {
case "documents":
return (
<div className="employee-tab-content">
<h3>Dokumente</h3>
<p>
Hier werden später Arbeitsverträge,
Nachweise und weitere Dokumente angezeigt.
</p>
</div>
);
return <DocumentsList employeeId={employee.id} />;
case "contracts":
return (
<div className="employee-tab-content">
<h3>Verträge</h3>
<p>
Hier erscheint später der Arbeitsvertrag
mit Beginn, Ende und Konditionen.
</p>
</div>
);
case "qualifications":
return (
<div className="employee-tab-content">
<h3>Qualifikationen</h3>
<p>
Hier erscheinen später Ausbildung,
Zertifikate und Fortbildungen.
</p>
</div>
);
return <ContractsList employeeId={employee.id} />;
case "assignments":
return (
@@ -172,11 +164,18 @@ export default function EmployeeDetailPanel({ employee, onUpdated }) {
Bearbeiten
</OmsorgButton>
)}
{canDelete && (
<OmsorgButton variant="secondary" icon={Trash2} onClick={handleDelete} disabled={isDeleting}>
Löschen
</OmsorgButton>
)}
</div>
<EmployeeTabs
activeTab={activeTab}
onChange={setActiveTab}
hiddenTabIds={canViewDocuments ? [] : ["documents"]}
/>
{renderTabContent()}
@@ -73,6 +73,7 @@ export function employeeFormToPayload(form, { includeStatus = false } = {}) {
export default function EmployeeForm({ form, onChange, includeStatus = false }) {
const { items: statusOptions } = useValueListItems("EmployeeStatus");
const { items: employmentTypes } = useValueListItems("EmploymentType");
const { items: qualifications } = useValueListItems("Qualification");
function updateField(field) {
return (event) => onChange({ ...form, [field]: event.target.value });
@@ -168,7 +169,14 @@ export default function EmployeeForm({ form, onChange, includeStatus = false })
<label className="form-field">
<span>Qualifikation</span>
<input value={form.qualification} onChange={updateField("qualification")} />
<select value={form.qualification} onChange={updateField("qualification")}>
<option value=""> nicht angegeben </option>
{qualifications.map((option) => (
<option key={option.id} value={option.value}>
{option.value}
</option>
))}
</select>
</label>
<fieldset className="form-field form-field--fieldset">
@@ -11,10 +11,6 @@ const tabs = [
id: "contracts",
label: "Verträge",
},
{
id: "qualifications",
label: "Qualifikationen",
},
{
id: "assignments",
label: "Einsätze",
@@ -24,6 +20,7 @@ const tabs = [
export default function EmployeeTabs({
activeTab = "overview",
onChange,
hiddenTabIds = [],
}) {
return (
<div
@@ -31,7 +28,7 @@ export default function EmployeeTabs({
role="tablist"
aria-label="Bereiche der Personalakte"
>
{tabs.map((tab) => {
{tabs.filter((tab) => !hiddenTabIds.includes(tab.id)).map((tab) => {
const isActive = activeTab === tab.id;
return (
@@ -31,8 +31,8 @@ export default function EmployeesPage() {
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");
const canViewAccounts = hasPermission("Users", "View");
const canCreateAccounts = hasPermission("Users", "Create");
const [employees, setEmployees] = useState([]);
const [totalCount, setTotalCount] = useState(0);
@@ -0,0 +1,101 @@
import { useState } from "react";
import ModalPortal from "../../components/ui/ModalPortal";
import { useValueListItems } from "../../app/useValueListItems";
import { FormActions } from "./ContractForm";
function errorMessage(result) {
if (result.status === 403) {
return "Keine Berechtigung, Dokumente hochzuladen.";
}
if (result.status === 400) {
return typeof result.data === "string" ? result.data : "Eingaben prüfen.";
}
return "Dokument konnte nicht hochgeladen werden.";
}
export default function UploadDocumentDialog({ employeeId, onClose, onUploaded }) {
const { items: categories } = useValueListItems("DocumentCategory");
const [category, setCategory] = useState("");
const [description, setDescription] = useState("");
const [file, setFile] = useState(null);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState(null);
async function handleSubmit(event) {
event.preventDefault();
if (!category) {
setError("Kategorie ist erforderlich.");
return;
}
if (!file) {
setError("Datei ist erforderlich.");
return;
}
setIsSaving(true);
setError(null);
const data = await file.arrayBuffer();
const result = await window.omsorg.documents.upload({
entityType: "Employee",
entityId: employeeId,
category,
description: description.trim() || null,
fileName: file.name,
contentType: file.type,
data,
});
setIsSaving(false);
if (!result.ok) {
setError(errorMessage(result));
return;
}
onUploaded(result.data);
}
return (
<ModalPortal>
<div className="modal-overlay" role="dialog" aria-modal="true" aria-label="Dokument hochladen">
<div className="modal-panel">
<h2>Dokument hochladen</h2>
<form onSubmit={handleSubmit}>
<div className="form-grid">
<label className="form-field">
<span>Kategorie *</span>
<select value={category} onChange={(event) => setCategory(event.target.value)} required>
<option value=""> auswählen </option>
{categories.map((option) => (
<option key={option.id} value={option.value}>
{option.value}
</option>
))}
</select>
</label>
<label className="form-field">
<span>Datei *</span>
<input type="file" onChange={(event) => setFile(event.target.files?.[0] ?? null)} required />
</label>
<label className="form-field">
<span>Beschreibung</span>
<textarea value={description} onChange={(event) => setDescription(event.target.value)} />
</label>
</div>
{error && <p className="login-error form-error">{error}</p>}
<FormActions onCancel={onClose} isSaving={isSaving} submitLabel="Hochladen" />
</form>
</div>
</div>
</ModalPortal>
);
}
@@ -0,0 +1,68 @@
import { useState } from "react";
import FacilityQualificationRateForm, {
emptyFacilityQualificationRateForm,
facilityQualificationRateFormToPayload,
FormActions,
} from "./FacilityQualificationRateForm";
import ModalPortal from "../../components/ui/ModalPortal";
function errorMessage(result) {
if (result.status === 403) {
return "Keine Berechtigung, Qualifikationspreise anzulegen.";
}
if (result.status === 400) {
return typeof result.data === "string" ? result.data : "Eingaben prüfen.";
}
return "Qualifikationspreis konnte nicht angelegt werden.";
}
export default function CreateFacilityQualificationRateDialog({ facilityId, onClose, onCreated }) {
const [form, setForm] = useState(emptyFacilityQualificationRateForm);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState(null);
async function handleSubmit(event) {
event.preventDefault();
if (!form.qualification) {
setError("Qualifikation ist erforderlich.");
return;
}
setIsSaving(true);
setError(null);
const result = await window.omsorg.facilityQualificationRates.create(
facilityId,
facilityQualificationRateFormToPayload(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 Qualifikationspreis">
<div className="modal-panel">
<h2>Neuer Qualifikationspreis</h2>
<form onSubmit={handleSubmit}>
<FacilityQualificationRateForm form={form} onChange={setForm} />
{error && <p className="login-error form-error">{error}</p>}
<FormActions onCancel={onClose} isSaving={isSaving} submitLabel="Speichern" />
</form>
</div>
</div>
</ModalPortal>
);
}
@@ -1,7 +1,9 @@
import { useState } from "react";
import FacilityForm, { facilityFormToPayload, facilityToFormValues, FormActions } from "./FacilityForm";
import FollowUpDaysDialog from "./FollowUpDaysDialog";
import ModalPortal from "../../components/ui/ModalPortal";
import { useValueListItems } from "../../app/useValueListItems";
function errorMessage(result) {
if (result.status === 403) {
@@ -14,22 +16,17 @@ function errorMessage(result) {
}
export default function EditFacilityDialog({ facility, onClose, onUpdated }) {
const { items: crmStatusOptions } = useValueListItems("CrmStatus");
const [form, setForm] = useState(() => facilityToFormValues(facility));
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState(null);
const [isFollowUpDialogOpen, setIsFollowUpDialogOpen] = useState(false);
async function handleSubmit(event) {
event.preventDefault();
if (!form.name.trim()) {
setError("Name ist erforderlich.");
return;
}
async function save(followUpDays) {
setIsSaving(true);
setError(null);
const payload = facilityFormToPayload(form, { includeCrmStatus: true });
const payload = facilityFormToPayload(form, { includeCrmStatus: true, followUpDays });
const result = await window.omsorg.facilities.update(facility.id, payload);
setIsSaving(false);
@@ -42,6 +39,24 @@ export default function EditFacilityDialog({ facility, onClose, onUpdated }) {
onUpdated(result.data);
}
async function handleSubmit(event) {
event.preventDefault();
if (!form.name.trim()) {
setError("Name ist erforderlich.");
return;
}
const selectedCrmStatus = crmStatusOptions.find((option) => option.value === form.crmStatus);
const entersFollowUpTriggerStatus = Boolean(selectedCrmStatus?.triggersFollowUp) && facility.crmStatus !== form.crmStatus;
if (entersFollowUpTriggerStatus) {
setIsFollowUpDialogOpen(true);
return;
}
await save(null);
}
return (
<ModalPortal>
<div className="modal-overlay" role="dialog" aria-modal="true" aria-label="Einrichtung bearbeiten">
@@ -49,7 +64,7 @@ export default function EditFacilityDialog({ facility, onClose, onUpdated }) {
<h2>{facility.name} bearbeiten</h2>
<form onSubmit={handleSubmit}>
<FacilityForm form={form} onChange={setForm} includeCrmStatus />
<FacilityForm form={form} onChange={setForm} includeCrmStatus currentCrmStatus={facility.crmStatus} />
{error && <p className="login-error form-error">{error}</p>}
@@ -57,6 +72,16 @@ export default function EditFacilityDialog({ facility, onClose, onUpdated }) {
</form>
</div>
</div>
{isFollowUpDialogOpen && (
<FollowUpDaysDialog
onCancel={() => setIsFollowUpDialogOpen(false)}
onConfirm={(followUpDays) => {
setIsFollowUpDialogOpen(false);
save(followUpDays);
}}
/>
)}
</ModalPortal>
);
}
@@ -0,0 +1,66 @@
import { useState } from "react";
import FacilityQualificationRateForm, {
facilityQualificationRateFormToPayload,
facilityQualificationRateToFormValues,
FormActions,
} from "./FacilityQualificationRateForm";
import ModalPortal from "../../components/ui/ModalPortal";
function errorMessage(result) {
if (result.status === 403) {
return "Keine Berechtigung, Qualifikationspreise zu bearbeiten.";
}
if (result.status === 400) {
return typeof result.data === "string" ? result.data : "Eingaben prüfen.";
}
return "Qualifikationspreis konnte nicht gespeichert werden.";
}
export default function EditFacilityQualificationRateDialog({ facilityId, rate, onClose, onUpdated }) {
const [form, setForm] = useState(() => facilityQualificationRateToFormValues(rate));
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState(null);
async function handleSubmit(event) {
event.preventDefault();
if (!form.qualification) {
setError("Qualifikation ist erforderlich.");
return;
}
setIsSaving(true);
setError(null);
const payload = facilityQualificationRateFormToPayload(form);
const result = await window.omsorg.facilityQualificationRates.update(facilityId, rate.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="Qualifikationspreis bearbeiten">
<div className="modal-panel">
<h2>{rate.qualification} bearbeiten</h2>
<form onSubmit={handleSubmit}>
<FacilityQualificationRateForm form={form} onChange={setForm} />
{error && <p className="login-error form-error">{error}</p>}
<FormActions onCancel={onClose} isSaving={isSaving} submitLabel="Speichern" />
</form>
</div>
</div>
</ModalPortal>
);
}
@@ -24,6 +24,7 @@ export default function FacilitiesPage() {
const [searchTerm, setSearchTerm] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
const [crmStatusFilter, setCrmStatusFilter] = useState("");
const [followUpDueOnly, setFollowUpDueOnly] = useState(false);
const [isFilterOpen, setIsFilterOpen] = useState(false);
const [page, setPage] = useState(1);
const [isDialogOpen, setIsDialogOpen] = useState(false);
@@ -35,7 +36,7 @@ export default function FacilitiesPage() {
useEffect(() => {
setPage(1);
}, [debouncedSearch, crmStatusFilter]);
}, [debouncedSearch, crmStatusFilter, followUpDueOnly]);
const loadFacilities = useCallback(async () => {
setIsLoading(true);
@@ -44,6 +45,7 @@ export default function FacilitiesPage() {
const result = await window.omsorg.facilities.list({
search: debouncedSearch || undefined,
crmStatus: crmStatusFilter || undefined,
followUpDueOnly: followUpDueOnly || undefined,
page,
pageSize: PAGE_SIZE,
});
@@ -62,7 +64,7 @@ export default function FacilitiesPage() {
}
setIsLoading(false);
}, [debouncedSearch, crmStatusFilter, page]);
}, [debouncedSearch, crmStatusFilter, followUpDueOnly, page]);
useEffect(() => {
loadFacilities();
@@ -139,6 +141,15 @@ export default function FacilitiesPage() {
))}
</select>
</label>
<label className="form-field form-field--checkbox">
<input
type="checkbox"
checked={followUpDueOnly}
onChange={(event) => setFollowUpDueOnly(event.target.checked)}
/>
<span>Nur fällige Wiedervorlagen</span>
</label>
</div>
)}
</OmsorgCard>
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from "react";
import { Pencil, Plus } from "lucide-react";
import { Pencil, Plus, Trash2 } from "lucide-react";
import OmsorgButton from "../../components/ui/OmsorgButton";
import { useAuth } from "../../app/AuthContext";
@@ -10,12 +10,14 @@ export default function FacilityContactsList({ facilityId }) {
const { hasPermission } = useAuth();
const canCreate = hasPermission("Facilities", "Create");
const canEdit = hasPermission("Facilities", "Edit");
const canDelete = hasPermission("Facilities", "Delete");
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 [deletingContactId, setDeletingContactId] = useState(null);
const loadContacts = useCallback(async () => {
setIsLoading(true);
@@ -46,6 +48,20 @@ export default function FacilityContactsList({ facilityId }) {
loadContacts();
}
async function handleDelete(contact) {
if (!window.confirm(`${contact.name} wirklich löschen?`)) {
return;
}
setDeletingContactId(contact.id);
const result = await window.omsorg.facilityContacts.delete(facilityId, contact.id);
setDeletingContactId(null);
if (result.ok) {
loadContacts();
}
}
return (
<div className="employee-tab-content">
<div className="page-heading">
@@ -86,6 +102,17 @@ export default function FacilityContactsList({ facilityId }) {
Bearbeiten
</OmsorgButton>
)}
{canDelete && (
<OmsorgButton
variant="secondary"
icon={Trash2}
onClick={() => handleDelete(contact)}
disabled={deletingContactId === contact.id}
>
Löschen
</OmsorgButton>
)}
</div>
))}
</div>
@@ -1,11 +1,19 @@
import { useState } from "react";
import { Pencil } from "lucide-react";
import { Pencil, Trash2 } 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";
import FacilityQualificationRatesList from "./FacilityQualificationRatesList";
function formatFollowUpDueDate(followUpDueDate) {
if (!followUpDueDate) {
return "—";
}
return new Date(followUpDueDate).toLocaleDateString("de-DE");
}
function formatAddress(street, postalCode, city, country) {
const line = [postalCode, city].filter(Boolean).join(" ");
@@ -21,7 +29,23 @@ function formatAddress(street, postalCode, city, country) {
export default function FacilityDetailPanel({ facility, onUpdated }) {
const { hasPermission } = useAuth();
const canEdit = hasPermission("Facilities", "Edit");
const canDelete = hasPermission("Facilities", "Delete");
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
async function handleDelete() {
if (!window.confirm(`${facility.name} wirklich löschen?`)) {
return;
}
setIsDeleting(true);
const result = await window.omsorg.facilities.delete(facility.id);
setIsDeleting(false);
if (result.ok) {
onUpdated?.(null);
}
}
if (!facility) {
return (
@@ -48,6 +72,12 @@ export default function FacilityDetailPanel({ facility, onUpdated }) {
Bearbeiten
</OmsorgButton>
)}
{canDelete && (
<OmsorgButton variant="secondary" icon={Trash2} onClick={handleDelete} disabled={isDeleting}>
Löschen
</OmsorgButton>
)}
</div>
<div className="employee-detail-grid">
@@ -85,8 +115,59 @@ export default function FacilityDetailPanel({ facility, onUpdated }) {
)}
</p>
</div>
{facility.followUpDueDate && (
<div>
<strong>Wiedervorlage fällig am</strong>
<p>{formatFollowUpDueDate(facility.followUpDueDate)}</p>
</div>
)}
<div>
<strong>Verrechnungssatz</strong>
<p>{facility.billingRate != null ? `${facility.billingRate} EUR/Std.` : "—"}</p>
</div>
<div>
<strong>Zuschläge</strong>
<p>
Nacht {facility.nightSurchargePercent ?? "—"}% · Sa {facility.saturdaySurchargePercent ?? "—"}% ·
So {facility.sundaySurchargePercent ?? "—"}% · Feiertag {facility.holidaySurchargePercent ?? "—"}%
</p>
</div>
<div>
<strong>Fahrtkosten</strong>
<p>{facility.travelCostRate != null ? `${facility.travelCostRate} EUR (Pauschale je Einsatz)` : "—"}</p>
</div>
<div>
<strong>Mindeststunden</strong>
<p>{facility.minimumHours ?? "—"}</p>
</div>
<div>
<strong>Pausenregelung</strong>
<p>{facility.breakPolicy ?? "—"}</p>
</div>
<div>
<strong>Abrechnungsintervall</strong>
<p>{facility.billingInterval ?? "—"}</p>
</div>
<div>
<strong>Zahlungsziel</strong>
<p>{facility.paymentTermDays != null ? `${facility.paymentTermDays} Tage` : "—"}</p>
</div>
<div>
<strong>Individuelle Vereinbarungen</strong>
<p>{facility.individualAgreements ?? "—"}</p>
</div>
</div>
<FacilityQualificationRatesList facilityId={facility.id} />
<FacilityContactsList facilityId={facility.id} />
</div>
@@ -1,3 +1,5 @@
import { useEffect, useState } from "react";
import OmsorgButton from "../../components/ui/OmsorgButton";
import { useValueListItems } from "../../app/useValueListItems";
@@ -14,6 +16,17 @@ export const emptyFacilityForm = {
billingPostalCode: "",
billingCity: "",
billingCountry: "",
billingRate: "",
nightSurchargePercent: "",
saturdaySurchargePercent: "",
sundaySurchargePercent: "",
holidaySurchargePercent: "",
travelCostRate: "",
minimumHours: "",
breakPolicy: "",
billingInterval: "",
paymentTermDays: "",
individualAgreements: "",
};
export function facilityToFormValues(facility) {
@@ -30,10 +43,21 @@ export function facilityToFormValues(facility) {
billingPostalCode: facility.billingPostalCode ?? "",
billingCity: facility.billingCity ?? "",
billingCountry: facility.billingCountry ?? "",
billingRate: facility.billingRate ?? "",
nightSurchargePercent: facility.nightSurchargePercent ?? "",
saturdaySurchargePercent: facility.saturdaySurchargePercent ?? "",
sundaySurchargePercent: facility.sundaySurchargePercent ?? "",
holidaySurchargePercent: facility.holidaySurchargePercent ?? "",
travelCostRate: facility.travelCostRate ?? "",
minimumHours: facility.minimumHours ?? "",
breakPolicy: facility.breakPolicy ?? "",
billingInterval: facility.billingInterval ?? "",
paymentTermDays: facility.paymentTermDays ?? "",
individualAgreements: facility.individualAgreements ?? "",
};
}
export function facilityFormToPayload(form, { includeCrmStatus = false } = {}) {
export function facilityFormToPayload(form, { includeCrmStatus = false, followUpDays = null } = {}) {
const payload = {
name: form.name.trim(),
facilityType: form.facilityType || null,
@@ -50,14 +74,52 @@ export function facilityFormToPayload(form, { includeCrmStatus = false } = {}) {
if (includeCrmStatus) {
payload.crmStatus = form.crmStatus;
payload.followUpDays = followUpDays;
payload.billingRate = form.billingRate === "" ? null : Number(form.billingRate);
payload.nightSurchargePercent = form.nightSurchargePercent === "" ? null : Number(form.nightSurchargePercent);
payload.saturdaySurchargePercent = form.saturdaySurchargePercent === "" ? null : Number(form.saturdaySurchargePercent);
payload.sundaySurchargePercent = form.sundaySurchargePercent === "" ? null : Number(form.sundaySurchargePercent);
payload.holidaySurchargePercent = form.holidaySurchargePercent === "" ? null : Number(form.holidaySurchargePercent);
payload.travelCostRate = form.travelCostRate === "" ? null : Number(form.travelCostRate);
payload.minimumHours = form.minimumHours === "" ? null : Number(form.minimumHours);
payload.breakPolicy = form.breakPolicy.trim() || null;
payload.billingInterval = form.billingInterval || null;
payload.paymentTermDays = form.paymentTermDays === "" ? null : Number(form.paymentTermDays);
payload.individualAgreements = form.individualAgreements.trim() || null;
}
return payload;
}
export default function FacilityForm({ form, onChange, includeCrmStatus = false }) {
export default function FacilityForm({ form, onChange, includeCrmStatus = false, currentCrmStatus = null }) {
const { items: crmStatusOptions } = useValueListItems("CrmStatus");
const { items: facilityTypes } = useValueListItems("FacilityType");
const { items: billingIntervals } = useValueListItems("BillingInterval");
const [transitions, setTransitions] = useState([]);
useEffect(() => {
if (!includeCrmStatus) return;
let cancelled = false;
window.omsorg.valueLists.listTransitions("CrmStatus").then((result) => {
if (!cancelled) {
setTransitions(result.ok ? result.data ?? [] : []);
}
});
return () => {
cancelled = true;
};
}, [includeCrmStatus]);
const currentCrmStatusItem = crmStatusOptions.find((option) => option.value === currentCrmStatus);
const selectableCrmStatusOptions = currentCrmStatusItem
? crmStatusOptions.filter(
(option) =>
option.id === currentCrmStatusItem.id ||
transitions.some((t) => t.fromItemId === currentCrmStatusItem.id && t.toItemId === option.id)
)
: crmStatusOptions;
function updateField(field) {
return (event) => onChange({ ...form, [field]: event.target.value });
@@ -74,7 +136,7 @@ export default function FacilityForm({ form, onChange, includeCrmStatus = false
<label className="form-field">
<span>CRM-Status</span>
<select value={form.crmStatus} onChange={updateField("crmStatus")}>
{crmStatusOptions.map((option) => (
{selectableCrmStatusOptions.map((option) => (
<option key={option.id} value={option.value}>
{option.value}
</option>
@@ -151,6 +213,82 @@ export default function FacilityForm({ form, onChange, includeCrmStatus = false
<input value={form.billingCountry} onChange={updateField("billingCountry")} />
</label>
</fieldset>
{includeCrmStatus && (
<fieldset className="form-field form-field--fieldset">
<legend>Konditionen</legend>
<label className="form-field">
<span>Verrechnungssatz (EUR/Std.)</span>
<input type="number" min="0" step="0.01" value={form.billingRate} onChange={updateField("billingRate")} />
</label>
<div className="form-row">
<label className="form-field">
<span>Nachtzuschlag (%)</span>
<input type="number" min="0" step="0.1" value={form.nightSurchargePercent} onChange={updateField("nightSurchargePercent")} />
</label>
<label className="form-field">
<span>Samstagszuschlag (%)</span>
<input type="number" min="0" step="0.1" value={form.saturdaySurchargePercent} onChange={updateField("saturdaySurchargePercent")} />
</label>
</div>
<div className="form-row">
<label className="form-field">
<span>Sonntagszuschlag (%)</span>
<input type="number" min="0" step="0.1" value={form.sundaySurchargePercent} onChange={updateField("sundaySurchargePercent")} />
</label>
<label className="form-field">
<span>Feiertagszuschlag (%)</span>
<input type="number" min="0" step="0.1" value={form.holidaySurchargePercent} onChange={updateField("holidaySurchargePercent")} />
</label>
</div>
<div className="form-row">
<label className="form-field">
<span>Fahrtkosten (EUR, Pauschale je Einsatz)</span>
<input type="number" min="0" step="0.01" value={form.travelCostRate} onChange={updateField("travelCostRate")} />
</label>
<label className="form-field">
<span>Mindeststunden je Einsatz</span>
<input type="number" min="0" step="0.5" value={form.minimumHours} onChange={updateField("minimumHours")} />
</label>
</div>
<label className="form-field">
<span>Pausenregelung</span>
<input value={form.breakPolicy} onChange={updateField("breakPolicy")} />
</label>
<div className="form-row">
<label className="form-field">
<span>Abrechnungsintervall</span>
<select value={form.billingInterval} onChange={updateField("billingInterval")}>
<option value=""> nicht angegeben </option>
{billingIntervals.map((option) => (
<option key={option.id} value={option.value}>
{option.value}
</option>
))}
</select>
</label>
<label className="form-field">
<span>Zahlungsziel (Tage)</span>
<input type="number" min="0" step="1" value={form.paymentTermDays} onChange={updateField("paymentTermDays")} />
</label>
</div>
<label className="form-field">
<span>Individuelle Vereinbarungen</span>
<input value={form.individualAgreements} onChange={updateField("individualAgreements")} />
</label>
</fieldset>
)}
</div>
);
}
@@ -0,0 +1,63 @@
import OmsorgButton from "../../components/ui/OmsorgButton";
import { useValueListItems } from "../../app/useValueListItems";
export const emptyFacilityQualificationRateForm = {
qualification: "",
rate: "",
};
export function facilityQualificationRateToFormValues(rate) {
return {
qualification: rate.qualification ?? "",
rate: typeof rate.rate === "number" ? String(rate.rate) : "",
};
}
export function facilityQualificationRateFormToPayload(form) {
return {
qualification: form.qualification,
rate: Number(form.rate) || 0,
};
}
export default function FacilityQualificationRateForm({ form, onChange }) {
const { items: qualificationOptions } = useValueListItems("Qualification");
function updateField(field) {
return (event) => onChange({ ...form, [field]: event.target.value });
}
return (
<div className="form-grid">
<label className="form-field">
<span>Qualifikation *</span>
<select value={form.qualification} onChange={updateField("qualification")} required>
<option value=""> auswählen </option>
{qualificationOptions.map((option) => (
<option key={option.id} value={option.value}>
{option.value}
</option>
))}
</select>
</label>
<label className="form-field">
<span>Verrechnungssatz (EUR/Std.) *</span>
<input type="number" step="0.01" min="0" value={form.rate} onChange={updateField("rate")} required />
</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,130 @@
import { useCallback, useEffect, useState } from "react";
import { Pencil, Plus, Trash2 } from "lucide-react";
import OmsorgButton from "../../components/ui/OmsorgButton";
import { useAuth } from "../../app/AuthContext";
import CreateFacilityQualificationRateDialog from "./CreateFacilityQualificationRateDialog";
import EditFacilityQualificationRateDialog from "./EditFacilityQualificationRateDialog";
export default function FacilityQualificationRatesList({ facilityId }) {
const { hasPermission } = useAuth();
const canCreate = hasPermission("Facilities", "Create");
const canEdit = hasPermission("Facilities", "Edit");
const canDelete = hasPermission("Facilities", "Delete");
const [rates, setRates] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [editingRate, setEditingRate] = useState(null);
const [deletingRateId, setDeletingRateId] = useState(null);
const loadRates = useCallback(async () => {
setIsLoading(true);
setError(null);
const result = await window.omsorg.facilityQualificationRates.list(facilityId);
if (result.ok) {
setRates(result.data ?? []);
} else {
setError("Qualifikationspreise konnten nicht geladen werden.");
}
setIsLoading(false);
}, [facilityId]);
useEffect(() => {
loadRates();
}, [loadRates]);
function handleCreated() {
setIsDialogOpen(false);
loadRates();
}
function handleUpdated() {
setEditingRate(null);
loadRates();
}
async function handleDelete(rate) {
if (!window.confirm(`Qualifikationspreis "${rate.qualification}" wirklich löschen?`)) {
return;
}
setDeletingRateId(rate.id);
const result = await window.omsorg.facilityQualificationRates.delete(facilityId, rate.id);
setDeletingRateId(null);
if (result.ok) {
loadRates();
}
}
return (
<div className="employee-tab-content">
<div className="page-heading">
<h3>Qualifikationsabhängige Preise</h3>
{canCreate && (
<OmsorgButton icon={Plus} variant="secondary" onClick={() => setIsDialogOpen(true)}>
Neuer Qualifikationspreis
</OmsorgButton>
)}
</div>
{error && <p className="login-error">{error}</p>}
{isLoading && <p>Lädt...</p>}
{!isLoading && rates.length === 0 && <p>Noch kein Qualifikationspreis erfasst.</p>}
{!isLoading && rates.length > 0 && (
<div className="facility-contacts-list">
{rates.map((rate) => (
<div key={rate.id} className="facility-contact-row">
<div>
<strong>{rate.qualification}</strong>
<p>{rate.rate.toFixed(2)} EUR/Std.</p>
</div>
{canEdit && (
<OmsorgButton variant="secondary" icon={Pencil} onClick={() => setEditingRate(rate)}>
Bearbeiten
</OmsorgButton>
)}
{canDelete && (
<OmsorgButton
variant="secondary"
icon={Trash2}
onClick={() => handleDelete(rate)}
disabled={deletingRateId === rate.id}
>
Löschen
</OmsorgButton>
)}
</div>
))}
</div>
)}
{isDialogOpen && (
<CreateFacilityQualificationRateDialog
facilityId={facilityId}
onClose={() => setIsDialogOpen(false)}
onCreated={handleCreated}
/>
)}
{editingRate && (
<EditFacilityQualificationRateDialog
facilityId={facilityId}
rate={editingRate}
onClose={() => setEditingRate(null)}
onUpdated={handleUpdated}
/>
)}
</div>
);
}
@@ -0,0 +1,61 @@
import { useEffect, useState } from "react";
import ModalPortal from "../../components/ui/ModalPortal";
import OmsorgButton from "../../components/ui/OmsorgButton";
import { useValueListItems } from "../../app/useValueListItems";
export default function FollowUpDaysDialog({ onCancel, onConfirm }) {
const { items } = useValueListItems("FollowUpPeriods");
const [selectedDays, setSelectedDays] = useState(null);
useEffect(() => {
if (selectedDays === null && items.length > 0) {
const defaultItem = items.find((item) => item.isDefault) ?? items[0];
setSelectedDays(defaultItem.value);
}
}, [items, selectedDays]);
function handleConfirm(event) {
event.preventDefault();
if (selectedDays !== null) {
onConfirm(Number(selectedDays));
}
}
return (
<ModalPortal>
<div className="modal-overlay" role="dialog" aria-modal="true" aria-label="Wiedervorlage-Frist wählen">
<div className="modal-panel">
<h2>Wiedervorlage-Frist wählen</h2>
<p>In wie vielen Tagen soll diese Einrichtung erneut vorgelegt werden?</p>
<form onSubmit={handleConfirm}>
<div className="form-grid">
{items.map((item) => (
<label key={item.id} className="form-field form-field--radio">
<input
type="radio"
name="followUpDays"
value={item.value}
checked={selectedDays === item.value}
onChange={() => setSelectedDays(item.value)}
/>
<span>{item.value} Tage</span>
</label>
))}
</div>
<div className="form-actions">
<OmsorgButton variant="secondary" type="button" onClick={onCancel}>
Abbrechen
</OmsorgButton>
<OmsorgButton type="submit" disabled={selectedDays === null}>
Bestätigen
</OmsorgButton>
</div>
</form>
</div>
</div>
</ModalPortal>
);
}
@@ -0,0 +1,120 @@
import { useEffect, useState } from "react";
import { CalendarClock } from "lucide-react";
import OmsorgCard from "../../components/OmsorgCard";
import OmsorgBadge from "../../components/ui/OmsorgBadge";
import { useAuth } from "../../app/AuthContext";
const WIDGET_PAGE_SIZE = 5;
function daysRemaining(followUpDueDate) {
const due = new Date(followUpDueDate);
const now = new Date();
due.setHours(0, 0, 0, 0);
now.setHours(0, 0, 0, 0);
return Math.round((due - now) / (1000 * 60 * 60 * 24));
}
function badgeStatusFor(days) {
if (days <= 7) {
return "krank";
}
if (days <= 21) {
return "urlaub";
}
return "einsatz";
}
function remainingLabel(days) {
if (days < 0) {
return `überfällig seit ${Math.abs(days)} Tag${Math.abs(days) === 1 ? "" : "en"}`;
}
if (days === 0) {
return "fällig heute";
}
if (days === 1) {
return "fällig morgen";
}
return `fällig in ${days} Tagen`;
}
export default function FollowUpWidget() {
const { hasPermission } = useAuth();
const canView = hasPermission("Facilities", "View");
const [facilities, setFacilities] = useState([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
if (!canView) {
return;
}
let cancelled = false;
async function load() {
setIsLoading(true);
const result = await window.omsorg.facilities.list({
followUpDueOnly: true,
page: 1,
pageSize: WIDGET_PAGE_SIZE,
});
if (!cancelled) {
setFacilities(result.ok ? result.data?.items ?? [] : []);
setIsLoading(false);
}
}
load();
return () => {
cancelled = true;
};
}, [canView]);
if (!canView) {
return null;
}
return (
<OmsorgCard title="Fällige Wiedervorlagen">
<div className="contract-widget">
<div className="contract-widget__intro">
<CalendarClock size={20} />
<p>Einrichtungen, deren Wiedervorlage ansteht.</p>
</div>
{!isLoading && facilities.length === 0 && <p>Keine fälligen Wiedervorlagen.</p>}
<div className="contract-widget__list">
{facilities.map((facility) => {
const days = daysRemaining(facility.followUpDueDate);
const urgency = days <= 7 ? "danger" : days <= 21 ? "warning" : "info";
return (
<article key={facility.id} className={`contract-item contract-item--${urgency}`}>
<div className="contract-item__header">
<div>
<strong>{facility.name}</strong>
<span>{facility.crmStatus}</span>
</div>
<OmsorgBadge status={badgeStatusFor(days)} />
</div>
<div className="contract-item__meta">
<span>
Wiedervorlage: {new Date(facility.followUpDueDate).toLocaleDateString("de-DE")}
</span>
<strong>{remainingLabel(days)}</strong>
</div>
</article>
);
})}
</div>
</div>
</OmsorgCard>
);
}
+5 -27
View File
@@ -3,6 +3,8 @@ import OmsorgCard from "../../components/OmsorgCard";
import OmsorgButton from "../../components/ui/OmsorgButton";
import HomeStats from "./HomeStats";
import OmsorgBadge from "../../components/ui/OmsorgBadge";
import FollowUpWidget from "./FollowUpWidget";
import OrderStatusWidget from "./OrderStatusWidget";
export default function HomePage() {
return (
<>
@@ -27,14 +29,8 @@ export default function HomePage() {
</div>
<HomeStats />
<section className="dashboard-grid home-grid">
<OmsorgCard title="Heute zu erledigen">
<ul className="home-task-list">
<li> Vertrag verlängern</li>
<li>📄 3 Rechnungen offen</li>
<li>👤 Neuer Mitarbeiter beginnt</li>
<li> Führerschein läuft bald ab</li>
</ul>
</OmsorgCard>
<FollowUpWidget />
<OrderStatusWidget />
<OmsorgCard title="Schnellaktionen">
<div className="home-actions">
@@ -67,22 +63,4 @@ export default function HomePage() {
</section>
</>
);
}<OmsorgCard>
<h2>
Willkommen bei
<span className="accent"> Omsorg Business Controls Pro</span>
</h2>
<p>
Schön, dass ihr heute wieder da seid.
Hier beginnt euer Arbeitstag.
</p>
<div className="badge-test-row">
<OmsorgBadge status="aktiv" />
<OmsorgBadge status="einsatz" />
<OmsorgBadge status="urlaub" />
<OmsorgBadge status="krank" />
<OmsorgBadge status="vertrag" />
</div>
</OmsorgCard>
}
@@ -0,0 +1,77 @@
import { useEffect, useState } from "react";
import { ListChecks } from "lucide-react";
import OmsorgCard from "../../components/OmsorgCard";
import { useAuth } from "../../app/AuthContext";
import { useValueListItems } from "../../app/useValueListItems";
const WIDGET_PAGE_SIZE = 200;
export default function OrderStatusWidget() {
const { hasPermission } = useAuth();
const canView = hasPermission("Orders", "View");
const { items: statusItems } = useValueListItems("OrderStatus");
const [orders, setOrders] = useState([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
if (!canView) {
return undefined;
}
let cancelled = false;
async function load() {
setIsLoading(true);
const result = await window.omsorg.orders.list({ page: 1, pageSize: WIDGET_PAGE_SIZE });
if (!cancelled) {
setOrders(result.ok ? result.data?.items ?? [] : []);
setIsLoading(false);
}
}
load();
return () => {
cancelled = true;
};
}, [canView]);
if (!canView) {
return null;
}
const countByStatusId = new Map();
for (const order of orders) {
countByStatusId.set(order.statusId, (countByStatusId.get(order.statusId) ?? 0) + 1);
}
return (
<OmsorgCard title="Auftragsstatus">
<div className="contract-widget">
<div className="contract-widget__intro">
<ListChecks size={20} />
<p>Aktuelle Aufträge nach Status in der Bearbeitungspipeline.</p>
</div>
{!isLoading && orders.length === 0 && <p>Keine Aufträge vorhanden.</p>}
<div className="contract-widget__list">
{statusItems.map((status) => (
<article key={status.id} className="contract-item contract-item--info">
<div className="contract-item__header">
<div>
<strong>{status.value}</strong>
</div>
<strong>{countByStatusId.get(status.id) ?? 0}</strong>
</div>
</article>
))}
</div>
</div>
</OmsorgCard>
);
}
@@ -0,0 +1,66 @@
import { useState } from "react";
import OrderForm, { emptyOrderForm, orderFormToPayload, FormActions } from "./OrderForm";
import ModalPortal from "../../components/ui/ModalPortal";
function errorMessage(result) {
if (result.status === 403) {
return "Keine Berechtigung, Aufträge anzulegen.";
}
if (result.status === 400) {
return typeof result.data === "string" ? result.data : "Eingaben prüfen.";
}
return "Auftrag konnte nicht angelegt werden.";
}
export default function CreateOrderDialog({ facilities, onClose, onCreated }) {
const [form, setForm] = useState(emptyOrderForm);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState(null);
async function handleSubmit(event) {
event.preventDefault();
if (!form.facilityId || !form.startDate || !form.priority.trim()) {
setError("Einrichtung, Beginn und Priorität sind erforderlich.");
return;
}
if (form.endDate && form.endDate < form.startDate) {
setError("Das Ende darf nicht vor dem Beginn liegen.");
return;
}
setIsSaving(true);
setError(null);
const result = await window.omsorg.orders.create(orderFormToPayload(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 Auftrag">
<div className="modal-panel">
<h2>Neuer Auftrag</h2>
<form onSubmit={handleSubmit}>
<OrderForm form={form} onChange={setForm} facilities={facilities} />
{error && <p className="login-error form-error">{error}</p>}
<FormActions onCancel={onClose} isSaving={isSaving} submitLabel="Speichern" />
</form>
</div>
</div>
</ModalPortal>
);
}
@@ -0,0 +1,67 @@
import { useState } from "react";
import OrderForm, { orderFormToPayload, orderToFormValues, FormActions } from "./OrderForm";
import ModalPortal from "../../components/ui/ModalPortal";
function errorMessage(result) {
if (result.status === 403) {
return "Keine Berechtigung, Aufträge zu bearbeiten.";
}
if (result.status === 400) {
return typeof result.data === "string" ? result.data : "Eingaben prüfen.";
}
return "Auftrag konnte nicht gespeichert werden.";
}
export default function EditOrderDialog({ order, facilities, onClose, onUpdated }) {
const [form, setForm] = useState(() => orderToFormValues(order));
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState(null);
async function handleSubmit(event) {
event.preventDefault();
if (!form.facilityId || !form.startDate || !form.priority.trim()) {
setError("Einrichtung, Beginn und Priorität sind erforderlich.");
return;
}
if (form.endDate && form.endDate < form.startDate) {
setError("Das Ende darf nicht vor dem Beginn liegen.");
return;
}
setIsSaving(true);
setError(null);
const payload = orderFormToPayload(form, { includeStatus: true });
const result = await window.omsorg.orders.update(order.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="Auftrag bearbeiten">
<div className="modal-panel">
<h2>Auftrag bearbeiten</h2>
<form onSubmit={handleSubmit}>
<OrderForm form={form} onChange={setForm} facilities={facilities} includeStatus />
{error && <p className="login-error form-error">{error}</p>}
<FormActions onCancel={onClose} isSaving={isSaving} submitLabel="Speichern" />
</form>
</div>
</div>
</ModalPortal>
);
}
@@ -0,0 +1,118 @@
import { useState } from "react";
import { Pencil, Trash2 } from "lucide-react";
import OmsorgCard from "../../components/OmsorgCard";
import OmsorgButton from "../../components/ui/OmsorgButton";
import { useAuth } from "../../app/AuthContext";
import EditOrderDialog from "./EditOrderDialog";
function formatDate(value) {
if (!value) {
return "—";
}
return new Date(value).toLocaleDateString("de-DE");
}
export default function OrderDetailPanel({ order, facilityName, facilities, onUpdated }) {
const { hasPermission } = useAuth();
const canEdit = hasPermission("Orders", "Edit");
const canDelete = hasPermission("Orders", "Delete");
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
async function handleDelete() {
if (!window.confirm("Diesen Auftrag wirklich löschen?")) {
return;
}
setIsDeleting(true);
const result = await window.omsorg.orders.delete(order.id);
setIsDeleting(false);
if (result.ok) {
onUpdated?.(null);
}
}
if (!order) {
return (
<OmsorgCard>
<div className="employee-detail-empty">
<h2>Kein Auftrag ausgewählt</h2>
<p>Wähle links einen Auftrag aus.</p>
</div>
</OmsorgCard>
);
}
return (
<OmsorgCard>
<div className="employee-detail">
<div className="employee-detail-header">
<div>
<h2>{facilityName ?? "—"}</h2>
<span className="omsorg-badge omsorg-badge--neutral">{order.statusName}</span>
</div>
{canEdit && (
<OmsorgButton variant="secondary" icon={Pencil} onClick={() => setIsEditDialogOpen(true)}>
Bearbeiten
</OmsorgButton>
)}
{canDelete && (
<OmsorgButton variant="secondary" icon={Trash2} onClick={handleDelete} disabled={isDeleting}>
Löschen
</OmsorgButton>
)}
</div>
<div className="employee-detail-grid">
<div>
<strong>Zeitraum</strong>
<p>
{formatDate(order.startDate)} {order.endDate ? formatDate(order.endDate) : "offen"}
</p>
</div>
<div>
<strong>Benötigte Qualifikation</strong>
<p>{order.requiredQualification ?? "—"}</p>
</div>
<div>
<strong>Schichtart</strong>
<p>{order.shiftType ?? "—"}</p>
</div>
<div>
<strong>Anzahl benötigter Mitarbeiter</strong>
<p>{order.requiredHeadcount}</p>
</div>
<div>
<strong>Priorität</strong>
<p>{order.priority}</p>
</div>
<div>
<strong>Konditionen</strong>
<p>{order.conditions ?? "—"}</p>
</div>
</div>
</div>
{isEditDialogOpen && (
<EditOrderDialog
order={order}
facilities={facilities}
onClose={() => setIsEditDialogOpen(false)}
onUpdated={(updatedOrder) => {
setIsEditDialogOpen(false);
onUpdated?.(updatedOrder);
}}
/>
)}
</OmsorgCard>
);
}
+243
View File
@@ -0,0 +1,243 @@
import { useEffect, useState } from "react";
import OmsorgButton from "../../components/ui/OmsorgButton";
import { useValueListItems } from "../../app/useValueListItems";
export const emptyOrderForm = {
facilityId: "",
facilityContactId: "",
startDate: "",
endDate: "",
requiredQualification: "",
shiftType: "",
requiredHeadcount: 1,
conditions: "",
priority: "",
statusId: "",
};
// order.startDate/endDate kommen vom generierten API-Client als echte Date-Objekte
// (nicht als "YYYY-MM-DD"-String) - <input type="date"> akzeptiert aber nur diesen
// exakten Stringformat, sonst bleibt/wird das Feld unparsbar.
function toDateInputValue(value) {
if (!value) {
return "";
}
const date = value instanceof Date ? value : new Date(value);
return Number.isNaN(date.getTime()) ? "" : date.toISOString().slice(0, 10);
}
export function orderToFormValues(order) {
return {
facilityId: order.facilityId ?? "",
facilityContactId: order.facilityContactId ?? "",
startDate: toDateInputValue(order.startDate),
endDate: toDateInputValue(order.endDate),
requiredQualification: order.requiredQualification ?? "",
shiftType: order.shiftType ?? "",
requiredHeadcount: order.requiredHeadcount ?? 1,
conditions: order.conditions ?? "",
priority: order.priority ?? "",
statusId: order.statusId ?? "",
};
}
export function orderFormToPayload(form, { includeStatus = false } = {}) {
const payload = {
facilityId: form.facilityId,
facilityContactId: form.facilityContactId || null,
startDate: form.startDate,
endDate: form.endDate || null,
requiredQualification: form.requiredQualification.trim() || null,
shiftType: form.shiftType.trim() || null,
requiredHeadcount: Number(form.requiredHeadcount) || 1,
conditions: form.conditions.trim() || null,
priority: form.priority.trim(),
};
if (includeStatus) {
payload.statusId = form.statusId;
}
return payload;
}
export default function OrderForm({ form, onChange, facilities, includeStatus = false }) {
const [contacts, setContacts] = useState([]);
const { items: statusItems } = useValueListItems("OrderStatus");
const { items: qualifications } = useValueListItems("Qualification");
const { items: shiftTypes } = useValueListItems("ShiftType");
const { items: priorities } = useValueListItems("Priority");
const [transitions, setTransitions] = useState(null);
useEffect(() => {
let cancelled = false;
if (!form.facilityId) {
setContacts([]);
return undefined;
}
window.omsorg.facilityContacts.list(form.facilityId).then((result) => {
if (!cancelled) {
setContacts(result.ok ? result.data ?? [] : []);
}
});
return () => {
cancelled = true;
};
}, [form.facilityId]);
useEffect(() => {
if (!includeStatus) {
return;
}
let cancelled = false;
window.omsorg.valueLists.listTransitions("OrderStatus").then((result) => {
if (!cancelled) {
setTransitions(result.ok ? result.data ?? [] : []);
}
});
return () => {
cancelled = true;
};
}, [includeStatus]);
function updateField(field) {
return (event) => onChange({ ...form, [field]: event.target.value });
}
function handleFacilityChange(event) {
onChange({ ...form, facilityId: event.target.value, facilityContactId: "" });
}
const statusOptions =
transitions === null
? statusItems.filter((item) => item.id === form.statusId)
: statusItems.filter(
(item) =>
item.id === form.statusId ||
transitions.some((t) => t.fromItemId === form.statusId && t.toItemId === item.id)
);
return (
<div className="form-grid">
<label className="form-field">
<span>Einrichtung *</span>
<select value={form.facilityId} onChange={handleFacilityChange} required>
<option value=""> auswählen </option>
{facilities.map((facility) => (
<option key={facility.id} value={facility.id}>
{facility.name}
</option>
))}
</select>
</label>
<label className="form-field">
<span>Ansprechpartner</span>
<select value={form.facilityContactId} onChange={updateField("facilityContactId")} disabled={!form.facilityId}>
<option value=""> kein Ansprechpartner </option>
{contacts.map((contact) => (
<option key={contact.id} value={contact.id}>
{contact.name}
</option>
))}
</select>
</label>
<div className="form-row">
<label className="form-field">
<span>Beginn *</span>
<input type="date" value={form.startDate} onChange={updateField("startDate")} required />
</label>
<label className="form-field">
<span>Ende</span>
<input type="date" value={form.endDate} onChange={updateField("endDate")} />
</label>
</div>
<label className="form-field">
<span>Benötigte Qualifikation</span>
<select value={form.requiredQualification} onChange={updateField("requiredQualification")}>
<option value=""> nicht angegeben </option>
{qualifications.map((option) => (
<option key={option.id} value={option.value}>
{option.value}
</option>
))}
</select>
</label>
<label className="form-field">
<span>Schichtart</span>
<select value={form.shiftType} onChange={updateField("shiftType")}>
<option value=""> nicht angegeben </option>
{shiftTypes.map((option) => (
<option key={option.id} value={option.value}>
{option.value}
</option>
))}
</select>
</label>
<label className="form-field">
<span>Anzahl benötigter Mitarbeiter *</span>
<input
type="number"
min="1"
value={form.requiredHeadcount}
onChange={updateField("requiredHeadcount")}
required
/>
</label>
<label className="form-field">
<span>Priorität *</span>
<select value={form.priority} onChange={updateField("priority")} required>
<option value=""> auswählen </option>
{priorities.map((option) => (
<option key={option.id} value={option.value}>
{option.value}
</option>
))}
</select>
</label>
<label className="form-field">
<span>Konditionen</span>
<textarea value={form.conditions} onChange={updateField("conditions")} maxLength={500} />
</label>
{includeStatus && (
<label className="form-field">
<span>Status</span>
<select value={form.statusId} onChange={updateField("statusId")}>
{statusOptions.map((option) => (
<option key={option.id} value={option.id}>
{option.value}
</option>
))}
</select>
</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>
);
}
+278
View File
@@ -0,0 +1,278 @@
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 { useValueListItems } from "../../app/useValueListItems";
import OrderDetailPanel from "./OrderDetailPanel";
import CreateOrderDialog from "./CreateOrderDialog";
const PAGE_SIZE = 20;
export default function OrdersPage() {
const { hasPermission } = useAuth();
const canCreate = hasPermission("Orders", "Create");
const { items: statusOptions } = useValueListItems("OrderStatus");
const { items: priorityOptions } = useValueListItems("Priority");
const { items: qualificationOptions } = useValueListItems("Qualification");
const { items: shiftTypeOptions } = useValueListItems("ShiftType");
const [orders, setOrders] = useState([]);
const [facilities, setFacilities] = useState([]);
const [totalCount, setTotalCount] = useState(0);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
const [selectedOrderId, setSelectedOrderId] = useState(null);
const [searchTerm, setSearchTerm] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
const [statusFilter, setStatusFilter] = useState("");
const [priorityFilter, setPriorityFilter] = useState("");
const [facilityFilter, setFacilityFilter] = useState("");
const [qualificationFilter, setQualificationFilter] = useState("");
const [shiftTypeFilter, setShiftTypeFilter] = 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, statusFilter, priorityFilter, facilityFilter, qualificationFilter, shiftTypeFilter]);
useEffect(() => {
window.omsorg.facilities.list({ pageSize: 200 }).then((result) => {
if (result.ok) {
setFacilities(result.data?.items ?? []);
}
});
}, []);
const loadOrders = useCallback(async () => {
setIsLoading(true);
setError(null);
const result = await window.omsorg.orders.list({
search: debouncedSearch || undefined,
statusId: statusFilter || undefined,
priority: priorityFilter || undefined,
facilityId: facilityFilter || undefined,
requiredQualification: qualificationFilter || undefined,
shiftType: shiftTypeFilter || undefined,
page,
pageSize: PAGE_SIZE,
});
if (result.ok) {
const data = result.data ?? { items: [], totalCount: 0 };
setOrders(data.items ?? []);
setTotalCount(data.totalCount ?? 0);
setSelectedOrderId((current) =>
(data.items ?? []).some((order) => order.id === current) ? current : data.items?.[0]?.id ?? null
);
} else {
setError("Aufträge konnten nicht geladen werden.");
}
setIsLoading(false);
}, [debouncedSearch, statusFilter, priorityFilter, facilityFilter, qualificationFilter, shiftTypeFilter, page]);
useEffect(() => {
loadOrders();
}, [loadOrders]);
const facilityNameById = new Map(facilities.map((facility) => [facility.id, facility.name]));
const selectedOrder = orders.find((order) => order.id === selectedOrderId) ?? null;
const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE));
function handleCreated(createdOrder) {
setIsDialogOpen(false);
loadOrders();
if (createdOrder?.id) {
setSelectedOrderId(createdOrder.id);
}
}
function handleUpdated() {
loadOrders();
}
return (
<div className="employees-page">
<div className="page-heading">
<div>
<p className="page-eyebrow">Disposition</p>
<h1>Aufträge</h1>
<p className="page-description">Verwalte Aufträge und ihren Bearbeitungsstatus an einem Ort.</p>
</div>
{canCreate && (
<OmsorgButton icon={Plus} onClick={() => setIsDialogOpen(true)}>
Neuer Auftrag
</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="Auftrag suchen..."
aria-label="Auftrag 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>Status</span>
<select value={statusFilter} onChange={(event) => setStatusFilter(event.target.value)}>
<option value="">Alle</option>
{statusOptions.map((option) => (
<option key={option.id} value={option.id}>
{option.value}
</option>
))}
</select>
</label>
<label className="form-field">
<span>Priorität</span>
<select value={priorityFilter} onChange={(event) => setPriorityFilter(event.target.value)}>
<option value="">Alle</option>
{priorityOptions.map((option) => (
<option key={option.id} value={option.value}>
{option.value}
</option>
))}
</select>
</label>
<label className="form-field">
<span>Einrichtung</span>
<select value={facilityFilter} onChange={(event) => setFacilityFilter(event.target.value)}>
<option value="">Alle</option>
{facilities.map((facility) => (
<option key={facility.id} value={facility.id}>
{facility.name}
</option>
))}
</select>
</label>
<label className="form-field">
<span>Qualifikation</span>
<select value={qualificationFilter} onChange={(event) => setQualificationFilter(event.target.value)}>
<option value="">Alle</option>
{qualificationOptions.map((option) => (
<option key={option.id} value={option.value}>
{option.value}
</option>
))}
</select>
</label>
<label className="form-field">
<span>Schichtart</span>
<select value={shiftTypeFilter} onChange={(event) => setShiftTypeFilter(event.target.value)}>
<option value="">Alle</option>
{shiftTypeOptions.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">
{orders.map((order) => {
const isSelected = order.id === selectedOrderId;
return (
<button
key={order.id}
type="button"
className={
isSelected ? "employee-list-button employee-list-button--active" : "employee-list-button"
}
onClick={() => setSelectedOrderId(order.id)}
>
<OmsorgCard>
<div className="employee-row">
<div className="employee-main">
<strong>{facilityNameById.get(order.facilityId) ?? "—"}</strong>
{order.requiredQualification && <span> {order.requiredQualification}</span>}
</div>
<div className="employee-status">
<span className="omsorg-badge omsorg-badge--neutral">{order.statusName}</span>
</div>
</div>
</OmsorgCard>
</button>
);
})}
{orders.length === 0 && (
<OmsorgCard>
<div className="employees-empty-state">
<strong>Keinen Auftrag gefunden</strong>
<span>Prüfe den eingegebenen Suchbegriff oder die Filter.</span>
</div>
</OmsorgCard>
)}
{orders.length > 0 && (
<OmsorgPagination page={page} totalPages={totalPages} onPageChange={setPage} />
)}
</div>
<OrderDetailPanel
order={selectedOrder}
facilityName={selectedOrder ? facilityNameById.get(selectedOrder.facilityId) : null}
facilities={facilities}
onUpdated={handleUpdated}
/>
</div>
)}
{isDialogOpen && (
<CreateOrderDialog
facilities={facilities}
onClose={() => setIsDialogOpen(false)}
onCreated={handleCreated}
/>
)}
</div>
);
}
@@ -0,0 +1,128 @@
import { useState } from "react";
import { FormActions } from "../employees/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;
function errorMessage(result) {
if (result.status === 403) {
return "Keine Berechtigung, Passwörter zurückzusetzen.";
}
if (result.status === 404) {
return "Nutzer nicht gefunden.";
}
if (result.status === 400) {
return typeof result.data === "string" ? result.data : "Eingaben prüfen.";
}
return "Passwort konnte nicht zurückgesetzt werden.";
}
export default function ResetUserPasswordDialog({ user, onClose, onDone }) {
const { passwordMinLength } = useAuth();
const [mode, setMode] = useState("Invite");
const [pinValidityDays, setPinValidityDays] = useState(DEFAULT_PIN_VALIDITY_DAYS);
const [initialPassword, setInitialPassword] = useState("");
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState(null);
async function handleSubmit(event) {
event.preventDefault();
if (mode === "Direct" && initialPassword.trim().length < passwordMinLength) {
setError(`Initiales Passwort muss mindestens ${passwordMinLength} Zeichen lang sein.`);
return;
}
setIsSaving(true);
setError(null);
const result = await window.omsorg.users.resetPassword(user.id, {
mode,
initialPassword: mode === "Direct" ? initialPassword : null,
pinValidityDays: mode === "Invite" ? pinValidityDays : null,
});
setIsSaving(false);
if (!result.ok) {
setError(errorMessage(result));
return;
}
onDone();
}
return (
<ModalPortal>
<div className="modal-overlay" role="dialog" aria-modal="true" aria-label="Passwort zurücksetzen">
<div className="modal-panel">
<h2>Passwort für {user.username} zurücksetzen</h2>
<form onSubmit={handleSubmit}>
<fieldset className="form-field">
<legend>Zugang</legend>
<label>
<input
type="radio"
name="reset-mode"
value="Invite"
checked={mode === "Invite"}
onChange={() => setMode("Invite")}
/>
Einladung per Mail (Nutzer setzt eigenes Passwort)
</label>
<label>
<input
type="radio"
name="reset-mode"
value="Direct"
checked={mode === "Direct"}
onChange={() => setMode("Direct")}
/>
Passwort direkt vergeben (muss beim nächsten 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>Neues 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="Zurücksetzen" />
</form>
</div>
</div>
</ModalPortal>
);
}
@@ -1,14 +1,16 @@
import { useCallback, useEffect, useState } from "react";
import { Save } from "lucide-react";
import { PermissionScope } from "omsorgcore-client-ts";
import OmsorgButton from "../../components/ui/OmsorgButton";
import { MODULE_OPTIONS, ACTION_OPTIONS } from "./permissionOptions";
import { MODULE_OPTIONS, ACTION_OPTIONS, SCOPE_OPTIONS, SCOPE_CAPABLE_MODULES } from "./permissionOptions";
function permissionKey(module, action) {
return `${module}:${action}`;
}
// grants: Map<"module:action", PermissionScope> - Abwesenheit eines Eintrags = kein Zugriff.
export default function RolePermissionMatrix({ roleId, roleName, canEdit }) {
const [checked, setChecked] = useState(() => new Set());
const [grants, setGrants] = useState(() => new Map());
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState(null);
@@ -20,7 +22,7 @@ export default function RolePermissionMatrix({ roleId, roleName, canEdit }) {
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))));
setGrants(new Map(result.data.permissions.map((p) => [permissionKey(p.module, p.action), p.scope ?? PermissionScope.All])));
} else {
setError("Rechte konnten nicht geladen werden.");
}
@@ -34,12 +36,26 @@ export default function RolePermissionMatrix({ roleId, roleName, canEdit }) {
function toggle(module, action) {
if (!canEdit) return;
const key = permissionKey(module, action);
setChecked((prev) => {
const next = new Set(prev);
setGrants((prev) => {
const next = new Map(prev);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
next.set(key, PermissionScope.All);
}
return next;
});
}
function setScope(module, action, rawValue) {
if (!canEdit) return;
const key = permissionKey(module, action);
setGrants((prev) => {
const next = new Map(prev);
if (rawValue === "") {
next.delete(key);
} else {
next.set(key, rawValue);
}
return next;
});
@@ -50,9 +66,9 @@ export default function RolePermissionMatrix({ roleId, roleName, canEdit }) {
setError(null);
setSavedMessage(null);
const permissions = Array.from(checked).map((key) => {
const permissions = Array.from(grants.entries()).map(([key, scope]) => {
const [module, action] = key.split(":");
return { module, action };
return { module, action, scope };
});
const result = await window.omsorg.roles.updatePermissions(roleId, { permissions });
@@ -85,21 +101,45 @@ export default function RolePermissionMatrix({ roleId, roleName, canEdit }) {
</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>
))}
{MODULE_OPTIONS.map((module) => {
const scopeCapable = SCOPE_CAPABLE_MODULES.includes(module.value);
return (
<tr key={module.value}>
<td>{module.label}</td>
{ACTION_OPTIONS.map((action) => {
const key = permissionKey(module.value, action.value);
if (scopeCapable) {
return (
<td key={action.value}>
<select
value={grants.get(key) ?? ""}
onChange={(e) => setScope(module.value, action.value, e.target.value)}
disabled={!canEdit}
>
<option value="">Kein Zugriff</option>
{SCOPE_OPTIONS.map((scope) => (
<option key={scope.value} value={scope.value}>
{scope.label}
</option>
))}
</select>
</td>
);
}
return (
<td key={action.value}>
<input
type="checkbox"
checked={grants.has(key)}
onChange={() => toggle(module.value, action.value)}
disabled={!canEdit}
/>
</td>
);
})}
</tr>
);
})}
</tbody>
</table>
</div>
+34 -21
View File
@@ -1,36 +1,49 @@
import { useState } from "react";
import { useMemo, useState } from "react";
import OmsorgCard from "../../components/OmsorgCard";
import RolesPanel from "./RolesPanel";
import UserOverridesPanel from "./UserOverridesPanel";
import StatusManagementPanel from "./StatusManagementPanel";
import UsersPanel from "./UsersPanel";
import { useAuth } from "../../app/AuthContext";
const TABS = [
{ id: "roles", label: "Rollen" },
{ id: "overrides", label: "Benutzerrechte" },
{ id: "statuses", label: "Status-Verwaltung" },
const ALL_TABS = [
{ id: "users", label: "Benutzer", module: "Users" },
{ id: "roles", label: "Rollen", module: "UserManagement" },
{ id: "overrides", label: "Benutzerrechte", module: "UserManagement" },
{ id: "statuses", label: "Status-Verwaltung", module: "Configuration" },
];
export default function SettingsPage() {
const [activeTab, setActiveTab] = useState("roles");
const { hasPermission } = useAuth();
const tabs = useMemo(() => ALL_TABS.filter((tab) => hasPermission(tab.module, "View")), [hasPermission]);
const [activeTab, setActiveTab] = useState(tabs[0]?.id ?? null);
const currentTab = tabs.find((tab) => tab.id === activeTab) ? activeTab : tabs[0]?.id ?? null;
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>
{tabs.length === 0 && <p>Keine Berechtigung für diesen Bereich.</p>}
{activeTab === "roles" && <RolesPanel />}
{activeTab === "overrides" && <UserOverridesPanel />}
{activeTab === "statuses" && <StatusManagementPanel />}
{tabs.length > 0 && (
<>
<div className="settings-tabs">
{tabs.map((tab) => (
<button
key={tab.id}
type="button"
className={tab.id === currentTab ? "settings-tab settings-tab--active" : "settings-tab"}
onClick={() => setActiveTab(tab.id)}
>
{tab.label}
</button>
))}
</div>
{currentTab === "users" && <UsersPanel />}
{currentTab === "roles" && <RolesPanel />}
{currentTab === "overrides" && <UserOverridesPanel />}
{currentTab === "statuses" && <StatusManagementPanel />}
</>
)}
</OmsorgCard>
);
}
@@ -1,14 +1,17 @@
import { Fragment, useCallback, useEffect, useState } from "react";
import { Plus, Save, Trash2, Info } from "lucide-react";
import { Plus, Save, Trash2, Info, ArrowUp, ArrowDown } from "lucide-react";
import OmsorgButton from "../../components/ui/OmsorgButton";
import { useAuth } from "../../app/AuthContext";
import { invalidateValueListCache } from "../../app/useValueListItems";
const ORDER_STATUS_KEY = "OrderStatus";
const CRM_STATUS_KEY = "CrmStatus";
const QUALIFICATION_KEY = "Qualification";
export default function StatusManagementPanel() {
const { hasPermission } = useAuth();
const canCreate = hasPermission("UserManagement", "Create");
const canEdit = hasPermission("UserManagement", "Edit");
const canCreate = hasPermission("Configuration", "Create");
const canEdit = hasPermission("Configuration", "Edit");
const [lists, setLists] = useState([]);
const [selectedKey, setSelectedKey] = useState(null);
@@ -96,6 +99,9 @@ function ValueListDetail({ listKey, displayName, canCreate, canEdit }) {
const [blockedUsages, setBlockedUsages] = useState([]);
const isOrderStatus = listKey === ORDER_STATUS_KEY;
const isCrmStatus = listKey === CRM_STATUS_KEY;
const isQualification = listKey === QUALIFICATION_KEY;
const columnCount = 5 + (isOrderStatus ? 2 : 0) + (isCrmStatus ? 1 : 0);
const loadItems = useCallback(async () => {
setIsLoading(true);
@@ -130,6 +136,7 @@ function ValueListDetail({ listKey, displayName, canCreate, canEdit }) {
isDefault: false,
isInitial: false,
isTerminal: false,
triggersFollowUp: false,
});
setIsCreating(false);
@@ -139,6 +146,7 @@ function ValueListDetail({ listKey, displayName, canCreate, canEdit }) {
}
setNewValue("");
invalidateValueListCache(listKey);
await loadItems();
}
@@ -150,7 +158,9 @@ function ValueListDetail({ listKey, displayName, canCreate, canEdit }) {
isDefault: true,
isInitial: item.isInitial,
isTerminal: item.isTerminal,
triggersFollowUp: item.triggersFollowUp,
});
invalidateValueListCache(listKey);
await loadItems();
}
@@ -162,11 +172,55 @@ function ValueListDetail({ listKey, displayName, canCreate, canEdit }) {
isDefault: item.isDefault,
isInitial: flag === "isInitial" ? !item.isInitial : item.isInitial,
isTerminal: flag === "isTerminal" ? !item.isTerminal : item.isTerminal,
triggersFollowUp: flag === "triggersFollowUp" ? !item.triggersFollowUp : item.triggersFollowUp,
});
invalidateValueListCache(listKey);
await loadItems();
}
async function handleMove(item, direction) {
if (!canEdit) return;
const sorted = [...items].sort((a, b) => a.sortOrder - b.sortOrder);
const index = sorted.findIndex((i) => i.id === item.id);
const swapIndex = index + direction;
if (swapIndex < 0 || swapIndex >= sorted.length) return;
const other = sorted[swapIndex];
await Promise.all([
window.omsorg.valueLists.updateItem(listKey, item.id, {
value: item.value,
sortOrder: other.sortOrder,
isDefault: item.isDefault,
isInitial: item.isInitial,
isTerminal: item.isTerminal,
triggersFollowUp: item.triggersFollowUp,
}),
window.omsorg.valueLists.updateItem(listKey, other.id, {
value: other.value,
sortOrder: item.sortOrder,
isDefault: other.isDefault,
isInitial: other.isInitial,
isTerminal: other.isTerminal,
triggersFollowUp: other.triggersFollowUp,
}),
]);
invalidateValueListCache(listKey);
await loadItems();
}
async function handleShowUsages(item) {
if (usagesByItemId[item.id]) {
setUsagesByItemId((prev) => {
const next = { ...prev };
delete next[item.id];
return next;
});
return;
}
const result = await window.omsorg.valueLists.getUsages(listKey, item.id);
setUsagesByItemId((prev) => ({ ...prev, [item.id]: result.ok ? result.data ?? [] : [] }));
}
@@ -177,6 +231,7 @@ function ValueListDetail({ listKey, displayName, canCreate, canEdit }) {
const result = await window.omsorg.valueLists.deleteItem(listKey, item.id);
if (result.ok) {
invalidateValueListCache(listKey);
await loadItems();
return;
}
@@ -199,21 +254,49 @@ function ValueListDetail({ listKey, displayName, canCreate, canEdit }) {
<h3>{displayName}</h3>
{error && <p className="login-error">{error}</p>}
{isQualification && (
<p className="debug-sessions-meta">
Reihenfolge = Rangfolge des Qualifikationsniveaus: oben das niedrigste Niveau, unten das höchste.
</p>
)}
<div className="settings-permission-matrix-scroll">
<table>
<thead>
<tr>
<th>Rang</th>
<th>Reihenfolge</th>
<th>Wert</th>
<th>Standard</th>
{isOrderStatus && <th>Start</th>}
{isOrderStatus && <th>Ende</th>}
{isCrmStatus && <th>Löst Wiedervorlage aus</th>}
<th></th>
</tr>
</thead>
<tbody>
{items.map((item) => (
{items.map((item, index) => (
<Fragment key={item.id}>
<tr key={item.id}>
<td>{index + 1}</td>
<td>
<div className="settings-sort-order-controls">
<OmsorgButton
icon={ArrowUp}
variant="secondary"
type="button"
disabled={!canEdit || index === 0}
onClick={() => handleMove(item, -1)}
/>
<OmsorgButton
icon={ArrowDown}
variant="secondary"
type="button"
disabled={!canEdit || index === items.length - 1}
onClick={() => handleMove(item, 1)}
/>
</div>
</td>
<td>{item.value}</td>
<td>
<input
@@ -244,6 +327,16 @@ function ValueListDetail({ listKey, displayName, canCreate, canEdit }) {
/>
</td>
)}
{isCrmStatus && (
<td>
<input
type="checkbox"
checked={item.triggersFollowUp}
disabled={!canEdit}
onChange={() => handleToggleFlag(item, "triggersFollowUp")}
/>
</td>
)}
<td>
<OmsorgButton
icon={Info}
@@ -267,7 +360,7 @@ function ValueListDetail({ listKey, displayName, canCreate, canEdit }) {
</tr>
{usagesByItemId[item.id] && (
<tr key={`${item.id}-usages`}>
<td colSpan={isOrderStatus ? 5 : 3}>
<td colSpan={columnCount}>
{usagesByItemId[item.id].length === 0 ? (
<span className="debug-sessions-meta">Wird nirgends verwendet.</span>
) : (
@@ -280,7 +373,7 @@ function ValueListDetail({ listKey, displayName, canCreate, canEdit }) {
)}
{blockedDeleteId === item.id && (
<tr key={`${item.id}-blocked`}>
<td colSpan={isOrderStatus ? 5 : 3}>
<td colSpan={columnCount}>
<p className="login-error">
Löschen nicht möglich - wird noch verwendet bei: {blockedUsages.map((u) => u.displayLabel).join(", ")}.
Bitte dort zuerst entfernen/ändern.
@@ -309,12 +402,14 @@ function ValueListDetail({ listKey, displayName, canCreate, canEdit }) {
)}
{createError && <p className="login-error">{createError}</p>}
{isOrderStatus && <OrderStatusTransitions items={items} canEdit={canEdit} />}
{(isOrderStatus || isCrmStatus) && (
<OrderStatusTransitions items={items} canEdit={canEdit} listKey={listKey} />
)}
</div>
);
}
function OrderStatusTransitions({ items, canEdit }) {
function OrderStatusTransitions({ items, canEdit, listKey }) {
const [transitions, setTransitions] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
@@ -322,10 +417,10 @@ function OrderStatusTransitions({ items, canEdit }) {
const loadTransitions = useCallback(async () => {
setIsLoading(true);
const result = await window.omsorg.valueLists.listTransitions(ORDER_STATUS_KEY);
const result = await window.omsorg.valueLists.listTransitions(listKey);
setTransitions(result.ok ? result.data ?? [] : []);
setIsLoading(false);
}, []);
}, [listKey]);
useEffect(() => {
loadTransitions();
@@ -349,7 +444,7 @@ function OrderStatusTransitions({ items, canEdit }) {
async function handleSave() {
setIsSaving(true);
setSavedMessage(null);
const result = await window.omsorg.valueLists.replaceTransitions(ORDER_STATUS_KEY, transitions);
const result = await window.omsorg.valueLists.replaceTransitions(listKey, transitions);
setIsSaving(false);
setSavedMessage(result.ok ? "Gespeichert." : "Speichern fehlgeschlagen.");
}
@@ -2,7 +2,7 @@ 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";
import { MODULE_OPTIONS, ACTION_OPTIONS, EFFECT_OPTIONS, SCOPE_OPTIONS } from "./permissionOptions";
function labelFor(options, value) {
return options.find((option) => option.value === value)?.label ?? value;
@@ -22,6 +22,7 @@ export default function UserOverridesPanel() {
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 [newScope, setNewScope] = useState(SCOPE_OPTIONS[0].value);
const [isAdding, setIsAdding] = useState(false);
const [addError, setAddError] = useState(null);
@@ -67,6 +68,7 @@ export default function UserOverridesPanel() {
module: newModule,
action: newAction,
effect: newEffect,
scope: newScope,
});
setIsAdding(false);
@@ -123,7 +125,9 @@ export default function UserOverridesPanel() {
<strong>
{labelFor(MODULE_OPTIONS, o.module)} · {labelFor(ACTION_OPTIONS, o.action)}
</strong>
<p className="debug-sessions-meta">{labelFor(EFFECT_OPTIONS, o.effect)}</p>
<p className="debug-sessions-meta">
{labelFor(EFFECT_OPTIONS, o.effect)} · {labelFor(SCOPE_OPTIONS, o.scope)}
</p>
</div>
{canEdit && (
<OmsorgButton icon={Trash2} variant="danger" onClick={() => handleDelete(o.id)}>
@@ -158,6 +162,13 @@ export default function UserOverridesPanel() {
</option>
))}
</select>
<select value={newScope} onChange={(event) => setNewScope(event.target.value)}>
{SCOPE_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>
@@ -0,0 +1,140 @@
import { useCallback, useEffect, useState } from "react";
import { KeyRound, UserCheck, UserX } from "lucide-react";
import OmsorgButton from "../../components/ui/OmsorgButton";
import { useAuth } from "../../app/AuthContext";
import ResetUserPasswordDialog from "./ResetUserPasswordDialog";
export default function UsersPanel() {
const { hasPermission } = useAuth();
const canEdit = hasPermission("Users", "Edit");
const [users, setUsers] = useState([]);
const [roles, setRoles] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
const [savingUserId, setSavingUserId] = useState(null);
const [resetPasswordUser, setResetPasswordUser] = useState(null);
const loadUsers = useCallback(async () => {
setIsLoading(true);
setError(null);
const [usersResult, rolesResult] = await Promise.all([
window.omsorg.users.list(),
window.omsorg.roles.list(),
]);
if (usersResult.ok) {
setUsers(usersResult.data ?? []);
} else {
setError("Nutzer konnten nicht geladen werden.");
}
if (rolesResult.ok) {
setRoles(rolesResult.data ?? []);
}
setIsLoading(false);
}, []);
useEffect(() => {
loadUsers();
}, [loadUsers]);
function roleIdForName(roleName) {
return roles.find((role) => role.name === roleName)?.id ?? "";
}
async function handleRoleChange(user, roleId) {
setSavingUserId(user.id);
const result = await window.omsorg.users.update(user.id, { roleId, isActive: user.isActive });
setSavingUserId(null);
if (!result.ok) {
setError("Rolle konnte nicht geändert werden.");
return;
}
await loadUsers();
}
async function handleToggleActive(user) {
setSavingUserId(user.id);
const result = await window.omsorg.users.update(user.id, {
roleId: roleIdForName(user.roleName),
isActive: !user.isActive,
});
setSavingUserId(null);
if (!result.ok) {
setError("Status konnte nicht geändert werden.");
return;
}
await loadUsers();
}
return (
<div className="settings-users-panel">
{isLoading && <p>Lädt...</p>}
{error && <p className="login-error">{error}</p>}
{!isLoading && !error && users.length === 0 && <p>Noch keine Benutzerkonten angelegt.</p>}
{!isLoading && users.length > 0 && (
<ul className="debug-sessions-list">
{users.map((user) => (
<li key={user.id} className="debug-sessions-row">
<div>
<strong>{user.username}</strong>
<p className="debug-sessions-meta">
{user.isActive ? "Aktiv" : "Inaktiv"}
{user.mustChangePassword ? " · Passwortwechsel beim nächsten Login" : ""}
</p>
</div>
{canEdit ? (
<select
value={roleIdForName(user.roleName)}
onChange={(event) => handleRoleChange(user, event.target.value)}
disabled={savingUserId === user.id}
>
{roles.map((role) => (
<option key={role.id} value={role.id}>
{role.name}
</option>
))}
</select>
) : (
<span>{user.roleName}</span>
)}
{canEdit && (
<div className="debug-sessions-actions">
<OmsorgButton
icon={user.isActive ? UserX : UserCheck}
variant={user.isActive ? "danger" : "secondary"}
disabled={savingUserId === user.id}
onClick={() => handleToggleActive(user)}
>
{user.isActive ? "Deaktivieren" : "Aktivieren"}
</OmsorgButton>
<OmsorgButton icon={KeyRound} variant="secondary" onClick={() => setResetPasswordUser(user)}>
Passwort zurücksetzen
</OmsorgButton>
</div>
)}
</li>
))}
</ul>
)}
{resetPasswordUser && (
<ResetUserPasswordDialog
user={resetPasswordUser}
onClose={() => setResetPasswordUser(null)}
onDone={() => {
setResetPasswordUser(null);
loadUsers();
}}
/>
)}
</div>
);
}
@@ -1,4 +1,4 @@
import { ModuleType, PermissionAction, PermissionEffect } from "omsorgcore-client-ts";
import { ModuleType, PermissionAction, PermissionEffect, PermissionScope } 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,
@@ -14,8 +14,12 @@ const MODULE_LABELS = {
[ModuleType.Invoices]: "Rechnungen",
[ModuleType.Recruiting]: "Recruiting",
[ModuleType.Controlling]: "Controlling",
[ModuleType.UserManagement]: "Nutzerverwaltung",
[ModuleType.UserManagement]: "Rollen & Rechte",
[ModuleType.AuditLog]: "Audit-Log",
[ModuleType.Documents]: "Dokumente",
[ModuleType.Users]: "Benutzerkonten",
[ModuleType.Configuration]: "Konfiguration",
[ModuleType.Absences]: "Abwesenheiten",
};
const ACTION_LABELS = {
@@ -25,6 +29,7 @@ const ACTION_LABELS = {
[PermissionAction.Delete]: "Löschen",
[PermissionAction.Export]: "Export",
[PermissionAction.Approve]: "Freigeben",
[PermissionAction.Recover]: "Wiederherstellen",
};
const EFFECT_LABELS = {
@@ -32,6 +37,11 @@ const EFFECT_LABELS = {
[PermissionEffect.Revoke]: "Entziehen",
};
const SCOPE_LABELS = {
[PermissionScope.All]: "Alle",
[PermissionScope.Own]: "Nur eigene",
};
function toOptions(enumObject, labels) {
return Object.values(enumObject).map((value) => ({ value, label: labels[value] ?? value }));
}
@@ -39,3 +49,9 @@ function toOptions(enumObject, labels) {
export const MODULE_OPTIONS = toOptions(ModuleType, MODULE_LABELS);
export const ACTION_OPTIONS = toOptions(PermissionAction, ACTION_LABELS);
export const EFFECT_OPTIONS = toOptions(PermissionEffect, EFFECT_LABELS);
export const SCOPE_OPTIONS = toOptions(PermissionScope, SCOPE_LABELS);
// Nur diese Module haben serverseitig einen Ownership-Anker (User.EmployeeId / Contract.EmployeeId,
// siehe omsorgCore/CLAUDE.md "Datenebenen-Scope") - für alle anderen Module bleibt Scope faktisch
// wirkungslos, die Matrix/Overrides-UI bietet die Auswahl deshalb nur hier an.
export const SCOPE_CAPABLE_MODULES = [ModuleType.Employees, ModuleType.Contracts, ModuleType.Absences, ModuleType.TimeEntries];
@@ -0,0 +1,61 @@
import { useState } from "react";
import TimeEntryForm, { FormActions, timeEntryFormToPayload, timeEntryToFormValues } from "./TimeEntryForm";
import ModalPortal from "../../components/ui/ModalPortal";
function errorMessage(result) {
if (result.status === 403) {
return "Keine Berechtigung, diese Zeiterfassung zu bearbeiten.";
}
if (result.status === 400) {
return typeof result.data === "string" ? result.data : "Eingaben prüfen.";
}
return "Zeiterfassung konnte nicht gespeichert werden.";
}
export default function EditTimeEntryDialog({ timeEntry, onClose, onUpdated }) {
const [form, setForm] = useState(() => timeEntryToFormValues(timeEntry));
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState(null);
async function handleSubmit(event) {
event.preventDefault();
if (!form.orderId || !form.date || !form.start || !form.end) {
setError("Auftrag, Datum, Beginn und Ende sind erforderlich.");
return;
}
setIsSaving(true);
setError(null);
const result = await window.omsorg.timeEntries.update(timeEntry.id, timeEntryFormToPayload(form));
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="Zeiterfassung bearbeiten">
<div className="modal-panel">
<h2>Zeiterfassung bearbeiten</h2>
<form onSubmit={handleSubmit}>
<TimeEntryForm 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,176 @@
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";
import { useValueListItems } from "../../app/useValueListItems";
import TimeEntryDetailPanel from "./TimeEntryDetailPanel";
const PAGE_SIZE = 20;
function formatDate(value) {
if (!value) {
return "—";
}
return new Date(value).toLocaleDateString("de-DE");
}
export default function TimeEntriesPage() {
const { hasPermission } = useAuth();
const { items: statusOptions } = useValueListItems("TimeEntryStatus");
const [timeEntries, setTimeEntries] = useState([]);
const [totalCount, setTotalCount] = useState(0);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
const [selectedTimeEntryId, setSelectedTimeEntryId] = useState(null);
const [statusFilter, setStatusFilter] = useState("");
const [isFilterOpen, setIsFilterOpen] = useState(false);
const [page, setPage] = useState(1);
useEffect(() => {
setPage(1);
}, [statusFilter]);
const loadTimeEntries = useCallback(async () => {
setIsLoading(true);
setError(null);
const result = await window.omsorg.timeEntries.list({
statusId: statusFilter || undefined,
page,
pageSize: PAGE_SIZE,
});
if (result.ok) {
const data = result.data ?? { items: [], totalCount: 0 };
setTimeEntries(data.items ?? []);
setTotalCount(data.totalCount ?? 0);
setSelectedTimeEntryId((current) =>
(data.items ?? []).some((entry) => entry.id === current) ? current : data.items?.[0]?.id ?? null
);
} else {
setError("Zeiterfassungen konnten nicht geladen werden.");
}
setIsLoading(false);
}, [statusFilter, page]);
useEffect(() => {
loadTimeEntries();
}, [loadTimeEntries]);
const selectedTimeEntry = timeEntries.find((entry) => entry.id === selectedTimeEntryId) ?? null;
const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE));
function handleChanged() {
loadTimeEntries();
}
if (!hasPermission("TimeEntries", "View")) {
return (
<OmsorgCard title="Zeiterfassung">
<p>Keine Berechtigung, Zeiterfassungen einzusehen.</p>
</OmsorgCard>
);
}
return (
<div className="employees-page">
<div className="page-heading">
<div>
<p className="page-eyebrow">Zeit</p>
<h1>Zeiterfassung</h1>
<p className="page-description">
Erfasste Schichten des Außendienstes prüfen und durch die Statuspipeline freigeben.
</p>
</div>
</div>
<OmsorgCard>
<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>Status</span>
<select value={statusFilter} onChange={(event) => setStatusFilter(event.target.value)}>
<option value="">Alle</option>
{statusOptions.map((option) => (
<option key={option.id} value={option.id}>
{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">
{timeEntries.map((entry) => {
const isSelected = entry.id === selectedTimeEntryId;
return (
<button
key={entry.id}
type="button"
className={
isSelected ? "employee-list-button employee-list-button--active" : "employee-list-button"
}
onClick={() => setSelectedTimeEntryId(entry.id)}
>
<OmsorgCard>
<div className="employee-row">
<div className="employee-main">
<strong>{entry.employeeName}</strong>
<span> {formatDate(entry.date)} {entry.facilityName}</span>
</div>
<div className="employee-status">
<span className="omsorg-badge omsorg-badge--neutral">{entry.statusName}</span>
</div>
</div>
</OmsorgCard>
</button>
);
})}
{timeEntries.length === 0 && (
<OmsorgCard>
<div className="employees-empty-state">
<strong>Keine Zeiterfassung gefunden</strong>
<span>Prüfe die eingestellten Filter.</span>
</div>
</OmsorgCard>
)}
{timeEntries.length > 0 && (
<OmsorgPagination page={page} totalPages={totalPages} onPageChange={setPage} />
)}
</div>
<TimeEntryDetailPanel timeEntry={selectedTimeEntry} onChanged={handleChanged} />
</div>
)}
</div>
);
}
@@ -0,0 +1,189 @@
import { useEffect, 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 { useValueListItems } from "../../app/useValueListItems";
import EditTimeEntryDialog from "./EditTimeEntryDialog";
function formatDate(value) {
if (!value) {
return "—";
}
return new Date(value).toLocaleDateString("de-DE");
}
function formatTime(value) {
if (!value) {
return "—";
}
return value.slice(0, 5);
}
export default function TimeEntryDetailPanel({ timeEntry, onChanged }) {
const { hasPermission } = useAuth();
const canDecide = hasPermission("TimeEntries", "Approve");
const canEdit = hasPermission("TimeEntries", "Edit");
const [adminNote, setAdminNote] = useState("");
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState(null);
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const { items: statusItems } = useValueListItems("TimeEntryStatus");
const [transitions, setTransitions] = useState([]);
useEffect(() => {
let cancelled = false;
window.omsorg.valueLists.listTransitions("TimeEntryStatus").then((result) => {
if (!cancelled) {
setTransitions(result.ok ? result.data ?? [] : []);
}
});
return () => {
cancelled = true;
};
}, []);
if (!timeEntry) {
return (
<OmsorgCard>
<div className="employee-detail-empty">
<h2>Keine Zeiterfassung ausgewählt</h2>
<p>Wähle links einen Eintrag aus.</p>
</div>
</OmsorgCard>
);
}
// Büro-Entscheidungen sind genau die Übergänge mit requiresApproval=true ab dem aktuellen Status
// (Selbst-Einreichung des Außendienstes läuft über einen anderen Endpoint, submit, nicht hier).
const decisionOptions = transitions
.filter((t) => t.fromItemId === timeEntry.statusId && t.requiresApproval)
.map((t) => statusItems.find((item) => item.id === t.toItemId))
.filter(Boolean);
async function handleDecision(statusId) {
setIsSaving(true);
setError(null);
const result = await window.omsorg.timeEntries.decide(timeEntry.id, { statusId, adminNote: adminNote.trim() || null });
setIsSaving(false);
if (!result.ok) {
setError(result.status === 403 ? "Keine Berechtigung, diesen Statuswechsel auszulösen." : "Statuswechsel konnte nicht gespeichert werden.");
return;
}
setAdminNote("");
onChanged?.(result.data);
}
return (
<OmsorgCard>
<div className="employee-detail">
<div className="employee-detail-header">
<div>
<h2>{timeEntry.employeeName}</h2>
<span className="omsorg-badge omsorg-badge--neutral">{timeEntry.statusName}</span>
</div>
{canEdit && timeEntry.isEditableByOwner && (
<OmsorgButton variant="secondary" icon={Pencil} onClick={() => setIsEditDialogOpen(true)}>
Bearbeiten
</OmsorgButton>
)}
</div>
<div className="employee-detail-grid">
<div>
<strong>Einrichtung</strong>
<p>{timeEntry.facilityName}</p>
</div>
<div>
<strong>Datum</strong>
<p>{formatDate(timeEntry.date)}</p>
</div>
<div>
<strong>Beginn Ende</strong>
<p>
{formatTime(timeEntry.start)} {formatTime(timeEntry.end)}
</p>
</div>
<div>
<strong>Nachtstunden</strong>
<p>{timeEntry.nightHours}</p>
</div>
<div>
<strong>Samstagsstunden</strong>
<p>{timeEntry.saturdayHours}</p>
</div>
<div>
<strong>Sonntagsstunden</strong>
<p>{timeEntry.sundayHours}</p>
</div>
<div>
<strong>Feiertagsstunden</strong>
<p>{timeEntry.holidayHours}</p>
</div>
<div>
<strong>Kommentar</strong>
<p>{timeEntry.adminNote ?? "—"}</p>
</div>
</div>
{canDecide && (
<div className="form-grid">
{decisionOptions.length === 0 && (
<p style={{ color: "var(--omsorg-text-secondary)" }}>
Aus dem aktuellen Status ({timeEntry.statusName}) ist keine Büro-Entscheidung möglich.
</p>
)}
<label className="form-field">
<span>Kommentar (optional)</span>
<textarea value={adminNote} onChange={(event) => setAdminNote(event.target.value)} maxLength={500} />
</label>
{error && <p className="login-error form-error">{error}</p>}
{decisionOptions.length > 0 && (
<div className="form-actions">
{decisionOptions.map((option) => (
<OmsorgButton
key={option.id}
variant="secondary"
disabled={isSaving}
onClick={() => handleDecision(option.id)}
>
{option.value}
</OmsorgButton>
))}
</div>
)}
</div>
)}
</div>
{isEditDialogOpen && (
<EditTimeEntryDialog
timeEntry={timeEntry}
onClose={() => setIsEditDialogOpen(false)}
onUpdated={(updatedTimeEntry) => {
setIsEditDialogOpen(false);
onChanged?.(updatedTimeEntry);
}}
/>
)}
</OmsorgCard>
);
}
@@ -0,0 +1,172 @@
import { useEffect, useState } from "react";
import OmsorgButton from "../../components/ui/OmsorgButton";
export const emptyTimeEntryForm = {
orderId: "",
date: "",
start: "",
end: "",
breakMinutes: 0,
nightHours: 0,
saturdayHours: 0,
sundayHours: 0,
holidayHours: 0,
};
function toDateInputValue(value) {
if (!value) {
return "";
}
const date = value instanceof Date ? value : new Date(value);
return Number.isNaN(date.getTime()) ? "" : date.toISOString().slice(0, 10);
}
// TimeEntry.BreakDuration kommt vom generierten API-Client als "HH:mm:ss"-String (TimeSpan) -
// das Formular arbeitet mit Minuten als Zahl, weil das für die Eingabe praktikabler ist.
function breakDurationToMinutes(value) {
if (!value) {
return 0;
}
const [hours, minutes] = value.split(":").map(Number);
return (hours || 0) * 60 + (minutes || 0);
}
function minutesToBreakDuration(minutes) {
const total = Number(minutes) || 0;
const hours = Math.floor(total / 60);
const rest = total % 60;
return `${String(hours).padStart(2, "0")}:${String(rest).padStart(2, "0")}:00`;
}
export function timeEntryToFormValues(timeEntry) {
return {
orderId: timeEntry.orderId ?? "",
date: toDateInputValue(timeEntry.date),
start: timeEntry.start?.slice(0, 5) ?? "",
end: timeEntry.end?.slice(0, 5) ?? "",
breakMinutes: breakDurationToMinutes(timeEntry.breakDuration),
nightHours: timeEntry.nightHours ?? 0,
saturdayHours: timeEntry.saturdayHours ?? 0,
sundayHours: timeEntry.sundayHours ?? 0,
holidayHours: timeEntry.holidayHours ?? 0,
};
}
export function timeEntryFormToPayload(form) {
return {
orderId: form.orderId,
date: form.date,
start: `${form.start}:00`,
end: `${form.end}:00`,
breakDuration: minutesToBreakDuration(form.breakMinutes),
nightHours: Number(form.nightHours) || 0,
saturdayHours: Number(form.saturdayHours) || 0,
sundayHours: Number(form.sundayHours) || 0,
holidayHours: Number(form.holidayHours) || 0,
};
}
export default function TimeEntryForm({ form, onChange }) {
const [orders, setOrders] = useState([]);
const [facilities, setFacilities] = useState([]);
useEffect(() => {
let cancelled = false;
Promise.all([
window.omsorg.orders.list({ pageSize: 200 }),
window.omsorg.facilities.list({ pageSize: 200 }),
]).then(([ordersResult, facilitiesResult]) => {
if (!cancelled) {
setOrders(ordersResult.ok ? ordersResult.data?.items ?? [] : []);
setFacilities(facilitiesResult.ok ? facilitiesResult.data?.items ?? [] : []);
}
});
return () => {
cancelled = true;
};
}, []);
const facilityNameById = new Map(facilities.map((facility) => [facility.id, facility.name]));
function updateField(field) {
return (event) => onChange({ ...form, [field]: event.target.value });
}
return (
<div className="form-grid">
<label className="form-field">
<span>Auftrag *</span>
<select value={form.orderId} onChange={updateField("orderId")} required>
<option value=""> auswählen </option>
{orders.map((order) => (
<option key={order.id} value={order.id}>
Auftrag {order.id.slice(0, 8)} {facilityNameById.get(order.facilityId) ?? "—"}
</option>
))}
</select>
</label>
<label className="form-field">
<span>Datum *</span>
<input type="date" value={form.date} onChange={updateField("date")} required />
</label>
<div className="form-row">
<label className="form-field">
<span>Beginn *</span>
<input type="time" value={form.start} onChange={updateField("start")} required />
</label>
<label className="form-field">
<span>Ende *</span>
<input type="time" value={form.end} onChange={updateField("end")} required />
</label>
</div>
<label className="form-field">
<span>Pause (Minuten)</span>
<input type="number" min="0" value={form.breakMinutes} onChange={updateField("breakMinutes")} />
</label>
<div className="form-row">
<label className="form-field">
<span>Nachtstunden</span>
<input type="number" min="0" step="0.25" value={form.nightHours} onChange={updateField("nightHours")} />
</label>
<label className="form-field">
<span>Samstagsstunden</span>
<input type="number" min="0" step="0.25" value={form.saturdayHours} onChange={updateField("saturdayHours")} />
</label>
</div>
<div className="form-row">
<label className="form-field">
<span>Sonntagsstunden</span>
<input type="number" min="0" step="0.25" value={form.sundayHours} onChange={updateField("sundayHours")} />
</label>
<label className="form-field">
<span>Feiertagsstunden</span>
<input type="number" min="0" step="0.25" value={form.holidayHours} onChange={updateField("holidayHours")} />
</label>
</div>
</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>
);
}
+195
View File
@@ -0,0 +1,195 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Search } from "lucide-react";
import OmsorgCard from "../../components/OmsorgCard";
import OmsorgButton from "../../components/ui/OmsorgButton";
import { useAuth } from "../../app/AuthContext";
function formatDate(value) {
if (!value) return "—";
return new Date(value).toLocaleString("de-DE");
}
const TABS = [
{
key: "employees",
label: "Mitarbeiter",
module: "Employees",
list: (search) => window.omsorg.trash.listEmployees(search),
restore: (id) => window.omsorg.trash.restoreEmployee(id),
displayName: (item) => `${item.firstName} ${item.lastName}`.trim(),
},
{
key: "facilities",
label: "Einrichtungen",
module: "Facilities",
list: (search) => window.omsorg.trash.listFacilities(search),
restore: (id) => window.omsorg.trash.restoreFacility(id),
displayName: (item) => item.name,
},
{
key: "contracts",
label: "Verträge",
module: "Contracts",
list: (search) => window.omsorg.trash.listContracts(search),
restore: (id) => window.omsorg.trash.restoreContract(id),
displayName: (item) => item.contractType,
},
{
key: "orders",
label: "Aufträge",
module: "Orders",
list: (search) => window.omsorg.trash.listOrders(search),
restore: (id) => window.omsorg.trash.restoreOrder(id),
displayName: (item) => item.requiredQualification ?? "Auftrag",
},
{
key: "facilityContacts",
label: "Ansprechpartner",
module: "Facilities",
list: (search) => window.omsorg.trash.listFacilityContacts(search),
restore: (id) => window.omsorg.trash.restoreFacilityContact(id),
displayName: (item) => item.name,
},
{
key: "facilityQualificationRates",
label: "Qualifikationspreise",
module: "Facilities",
list: (search) => window.omsorg.trash.listFacilityQualificationRates(search),
restore: (id) => window.omsorg.trash.restoreFacilityQualificationRate(id),
displayName: (item) => item.qualification,
},
{
key: "absences",
label: "Abwesenheiten",
module: "Absences",
list: (search) => window.omsorg.trash.listAbsences(search),
restore: (id) => window.omsorg.trash.restoreAbsence(id),
displayName: (item) => item.type,
},
{
key: "timeEntries",
label: "Zeiterfassung",
module: "TimeEntries",
list: (search) => window.omsorg.trash.listTimeEntries(search),
restore: (id) => window.omsorg.trash.restoreTimeEntry(id),
displayName: (item) => new Date(item.date).toLocaleDateString("de-DE"),
},
];
export default function TrashPage() {
const { hasPermission } = useAuth();
const visibleTabs = useMemo(() => TABS.filter((tab) => hasPermission(tab.module, "Recover")), [hasPermission]);
const [activeKey, setActiveKey] = useState(visibleTabs[0]?.key ?? null);
const [items, setItems] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
const [searchTerm, setSearchTerm] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
const [restoringId, setRestoringId] = useState(null);
const activeTab = visibleTabs.find((tab) => tab.key === activeKey) ?? visibleTabs[0] ?? null;
useEffect(() => {
const timer = setTimeout(() => setDebouncedSearch(searchTerm.trim()), 300);
return () => clearTimeout(timer);
}, [searchTerm]);
const loadItems = useCallback(async () => {
if (!activeTab) return;
setIsLoading(true);
setError(null);
const result = await activeTab.list(debouncedSearch || undefined);
if (result.ok) {
setItems(result.data ?? []);
} else {
setError("Papierkorb konnte nicht geladen werden.");
}
setIsLoading(false);
}, [activeTab, debouncedSearch]);
useEffect(() => {
loadItems();
}, [loadItems]);
async function handleRestore(id) {
if (!activeTab) return;
setRestoringId(id);
const result = await activeTab.restore(id);
setRestoringId(null);
if (result.ok) {
loadItems();
}
}
if (visibleTabs.length === 0) {
return (
<OmsorgCard title="Papierkorb">
<p>Keine Berechtigung, gelöschte Datensätze wiederherzustellen.</p>
</OmsorgCard>
);
}
return (
<OmsorgCard title="Papierkorb">
<p>Gelöschte Datensätze können hier wiederhergestellt werden.</p>
<div className="employees-toolbar">
{visibleTabs.map((tab) => (
<OmsorgButton
key={tab.key}
variant={tab.key === activeTab?.key ? "primary" : "secondary"}
onClick={() => setActiveKey(tab.key)}
>
{tab.label}
</OmsorgButton>
))}
</div>
<div className="employees-search">
<Search size={18} />
<input
type="search"
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
placeholder="Suchen..."
aria-label="Papierkorb durchsuchen"
/>
</div>
{isLoading && <p>Lädt...</p>}
{error && <p className="login-error">{error}</p>}
{!isLoading && !error && items.length === 0 && <p>Keine gelöschten Einträge.</p>}
{items.length > 0 && (
<ul className="debug-sessions-list">
{items.map((item) => (
<li key={item.id} className="debug-sessions-row">
<div>
<strong>{activeTab.displayName(item) || "—"}</strong>
<p className="debug-sessions-meta">Gelöscht am {formatDate(item.deletedAt)}</p>
</div>
<OmsorgButton
variant="secondary"
onClick={() => handleRestore(item.id)}
disabled={restoringId === item.id}
>
Wiederherstellen
</OmsorgButton>
</li>
))}
</ul>
)}
</OmsorgCard>
);
}
+73
View File
@@ -798,6 +798,74 @@ button.omsorg-button--danger {
margin: 0 0 18px;
}
.modal-panel--wide {
max-width: 1100px;
}
.modal-panel--viewer {
max-width: 95vw;
width: 95vw;
height: 90vh;
max-height: 90vh;
overflow: hidden;
display: flex;
flex-direction: column;
position: relative;
}
.modal-panel--viewer h2 {
padding-right: 40px;
flex-shrink: 0;
}
.document-viewer-body {
flex: 1;
min-height: 0;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
.document-viewer-content {
width: 100%;
height: 100%;
border: none;
}
.document-viewer-content--image {
width: auto;
height: auto;
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.modal-close-button {
position: absolute;
top: 20px;
right: 20px;
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
border: 1px solid var(--omsorg-border);
border-radius: 999px;
background: transparent;
color: inherit;
cursor: pointer;
}
.modal-close-button:hover {
background: rgba(255, 255, 255, 0.08);
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
@@ -1725,6 +1793,11 @@ button.omsorg-button--danger {
gap: 8px;
}
.settings-sort-order-controls {
display: flex;
gap: 4px;
}
.settings-roles-detail {
flex: 1;
min-width: 0;