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
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:
co-authored by
Claude Sonnet 5
parent
e9e96a57dc
commit
598dfcd38a
@@ -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 }));
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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 }));
|
||||
}
|
||||
@@ -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 } };
|
||||
}
|
||||
@@ -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 }));
|
||||
}
|
||||
@@ -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 }));
|
||||
}
|
||||
@@ -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 }));
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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 }));
|
||||
}
|
||||
@@ -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 }));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 }));
|
||||
}
|
||||
@@ -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 }));
|
||||
}
|
||||
@@ -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 }));
|
||||
}
|
||||
@@ -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 }));
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user