Add kilometer-based Fahrtkosten billing, self-service distance entry, role deletion
Docker-Images bauen und veröffentlichen / build (, omsorgCore/Dockerfile, omsorgcore) (push) Successful in 16s
Docker-Images bauen und veröffentlichen / build (, omsorgWeb/Dockerfile, omsorgweb) (push) Successful in 5s
Docker-Images bauen und veröffentlichen / build (, omsorgapp/Dockerfile, omsorgapp) (push) Successful in 19s
Docker-Images bauen und veröffentlichen / build (, omsorgCore/Dockerfile, omsorgcore) (push) Successful in 16s
Docker-Images bauen und veröffentlichen / build (, omsorgWeb/Dockerfile, omsorgweb) (push) Successful in 5s
Docker-Images bauen und veröffentlichen / build (, omsorgapp/Dockerfile, omsorgapp) (push) Successful in 19s
- Facility: TravelCostMode (Pauschale/ProKilometer) + TravelCostPerKm, alongside the existing flat rate; fixes FacilityService.UpdateAsync silently dropping all Konditionen fields on update. - New EmployeeFacilityDistance (Mitarbeiter x Einrichtung -> km) with full office-side CRUD in omsorgapp, plus a self-service endpoint/UI so field staff can maintain their own commute distance via OMSORG Connect (new ModuleType.EmployeeFacilityDistances, Own-scope, no Facilities access needed). - Roles can now be deleted (blocked with a 409 while still assigned to a user). - Fix employeesApi.js missing the Date-object conversion for dateOfBirth/entryDate/ exitDate that crashed employee creation whenever a date was filled in; add a clear/remove control for those date fields. - Requirements: flesh out FR-REC-3 with a staged Akquise cadence, add FR-REC-5/6 for CRM feature scope and success metrics. - Regenerate api-client-ts and api-client-php for all of the above. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
09dce2ab98
commit
ffa2c4a9e7
@@ -0,0 +1,26 @@
|
||||
import { EmployeeFacilityDistancesApi } from "omsorgcore-client-ts";
|
||||
import { configFor, callApi } from "./apiClientHelpers.js";
|
||||
|
||||
// Kapselt /api/facilities/{facilityId}/employee-distances von omsorgCore
|
||||
// (EmployeeFacilityDistancesController) über den generierten Client (omsorgcore-client-ts).
|
||||
// Mitarbeiter-Einrichtung-Entfernungen sind eine 1:n-Unterressource von Facility (FR-EIN-4,
|
||||
// Grundlage für die kilometerbasierte Fahrtkostenabrechnung).
|
||||
export async function listEmployeeFacilityDistances(accessToken, facilityId) {
|
||||
const api = new EmployeeFacilityDistancesApi(configFor(accessToken));
|
||||
return callApi(api.apiFacilitiesFacilityIdEmployeeDistancesGetRaw({ facilityId }));
|
||||
}
|
||||
|
||||
export async function createEmployeeFacilityDistance(accessToken, facilityId, payload) {
|
||||
const api = new EmployeeFacilityDistancesApi(configFor(accessToken));
|
||||
return callApi(api.apiFacilitiesFacilityIdEmployeeDistancesPostRaw({ facilityId, createEmployeeFacilityDistanceRequest: payload }));
|
||||
}
|
||||
|
||||
export async function updateEmployeeFacilityDistance(accessToken, facilityId, id, payload) {
|
||||
const api = new EmployeeFacilityDistancesApi(configFor(accessToken));
|
||||
return callApi(api.apiFacilitiesFacilityIdEmployeeDistancesIdPutRaw({ facilityId, id, updateEmployeeFacilityDistanceRequest: payload }));
|
||||
}
|
||||
|
||||
export async function deleteEmployeeFacilityDistance(accessToken, facilityId, id) {
|
||||
const api = new EmployeeFacilityDistancesApi(configFor(accessToken));
|
||||
return callApi(api.apiFacilitiesFacilityIdEmployeeDistancesIdDeleteRaw({ facilityId, id }));
|
||||
}
|
||||
@@ -4,6 +4,19 @@ 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.
|
||||
|
||||
// dateOfBirth/entryDate/exitDate 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 ordersApi.js für dasselbe Muster).
|
||||
function toDatePayload(payload) {
|
||||
return {
|
||||
...payload,
|
||||
dateOfBirth: payload.dateOfBirth ? new Date(payload.dateOfBirth) : payload.dateOfBirth,
|
||||
entryDate: payload.entryDate ? new Date(payload.entryDate) : payload.entryDate,
|
||||
exitDate: payload.exitDate ? new Date(payload.exitDate) : payload.exitDate
|
||||
};
|
||||
}
|
||||
|
||||
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 }));
|
||||
@@ -11,7 +24,7 @@ export async function listEmployees(accessToken, { search, status, employmentTyp
|
||||
|
||||
export async function createEmployee(accessToken, payload) {
|
||||
const api = new EmployeesApi(configFor(accessToken));
|
||||
return callApi(api.apiEmployeesPostRaw({ createEmployeeRequest: payload }));
|
||||
return callApi(api.apiEmployeesPostRaw({ createEmployeeRequest: toDatePayload(payload) }));
|
||||
}
|
||||
|
||||
export async function getEmployee(accessToken, id) {
|
||||
@@ -21,7 +34,7 @@ export async function getEmployee(accessToken, id) {
|
||||
|
||||
export async function updateEmployee(accessToken, id, payload) {
|
||||
const api = new EmployeesApi(configFor(accessToken));
|
||||
return callApi(api.apiEmployeesIdPutRaw({ id, updateEmployeeRequest: payload }));
|
||||
return callApi(api.apiEmployeesIdPutRaw({ id, updateEmployeeRequest: toDatePayload(payload) }));
|
||||
}
|
||||
|
||||
export async function deleteEmployee(accessToken, id) {
|
||||
|
||||
@@ -5,6 +5,7 @@ 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 employeeFacilityDistancesApi from "./employeeFacilityDistancesApi.js";
|
||||
import * as ordersApi from "./ordersApi.js";
|
||||
import * as absencesApi from "./absencesApi.js";
|
||||
import * as timeEntriesApi from "./timeEntriesApi.js";
|
||||
@@ -127,6 +128,12 @@ export function buildOmsorgApi() {
|
||||
update: withAuth(facilityQualificationRatesApi.updateFacilityQualificationRate),
|
||||
delete: withAuth(facilityQualificationRatesApi.deleteFacilityQualificationRate)
|
||||
},
|
||||
employeeFacilityDistances: {
|
||||
list: withAuth(employeeFacilityDistancesApi.listEmployeeFacilityDistances),
|
||||
create: withAuth(employeeFacilityDistancesApi.createEmployeeFacilityDistance),
|
||||
update: withAuth(employeeFacilityDistancesApi.updateEmployeeFacilityDistance),
|
||||
delete: withAuth(employeeFacilityDistancesApi.deleteEmployeeFacilityDistance)
|
||||
},
|
||||
orders: {
|
||||
list: withAuth(ordersApi.listOrders),
|
||||
create: withAuth(ordersApi.createOrder),
|
||||
@@ -162,7 +169,8 @@ export function buildOmsorgApi() {
|
||||
list: withAuth(rolesApi.listRoles),
|
||||
get: withAuth(rolesApi.getRole),
|
||||
create: withAuth(rolesApi.createRole),
|
||||
updatePermissions: withAuth(rolesApi.updateRolePermissions)
|
||||
updatePermissions: withAuth(rolesApi.updateRolePermissions),
|
||||
delete: withAuth(rolesApi.deleteRole)
|
||||
},
|
||||
auditLog: {
|
||||
list: withAuth(auditLogApi.listAuditLog)
|
||||
@@ -190,6 +198,8 @@ export function buildOmsorgApi() {
|
||||
restoreFacilityContact: withAuth(trashApi.restoreFacilityContact),
|
||||
listFacilityQualificationRates: withAuth(trashApi.listDeletedFacilityQualificationRates),
|
||||
restoreFacilityQualificationRate: withAuth(trashApi.restoreFacilityQualificationRate),
|
||||
listEmployeeFacilityDistances: withAuth(trashApi.listDeletedEmployeeFacilityDistances),
|
||||
restoreEmployeeFacilityDistance: withAuth(trashApi.restoreEmployeeFacilityDistance),
|
||||
listAbsences: withAuth(trashApi.listDeletedAbsences),
|
||||
restoreAbsence: withAuth(trashApi.restoreAbsence),
|
||||
listTimeEntries: withAuth(trashApi.listDeletedTimeEntries),
|
||||
|
||||
@@ -22,3 +22,8 @@ export async function updateRolePermissions(accessToken, roleId, payload) {
|
||||
const api = new RolesApi(configFor(accessToken));
|
||||
return callApi(api.apiRolesIdPermissionsPutRaw({ id: roleId, updateRolePermissionsRequest: payload }));
|
||||
}
|
||||
|
||||
export async function deleteRole(accessToken, roleId) {
|
||||
const api = new RolesApi(configFor(accessToken));
|
||||
return callApi(api.apiRolesIdDeleteRaw({ id: roleId }));
|
||||
}
|
||||
|
||||
@@ -64,6 +64,16 @@ export async function restoreFacilityQualificationRate(accessToken, id) {
|
||||
return callApi(api.apiTrashFacilityQualificationRatesIdRestorePostRaw({ id }));
|
||||
}
|
||||
|
||||
export async function listDeletedEmployeeFacilityDistances(accessToken, search) {
|
||||
const api = new TrashApi(configFor(accessToken));
|
||||
return callApi(api.apiTrashEmployeeFacilityDistancesGetRaw({ search }));
|
||||
}
|
||||
|
||||
export async function restoreEmployeeFacilityDistance(accessToken, id) {
|
||||
const api = new TrashApi(configFor(accessToken));
|
||||
return callApi(api.apiTrashEmployeeFacilityDistancesIdRestorePostRaw({ id }));
|
||||
}
|
||||
|
||||
export async function listDeletedAbsences(accessToken, search) {
|
||||
const api = new TrashApi(configFor(accessToken));
|
||||
return callApi(api.apiTrashAbsencesGetRaw({ search }));
|
||||
|
||||
@@ -79,6 +79,10 @@ export default function EmployeeForm({ form, onChange, includeStatus = false })
|
||||
return (event) => onChange({ ...form, [field]: event.target.value });
|
||||
}
|
||||
|
||||
function clearField(field) {
|
||||
return () => onChange({ ...form, [field]: "" });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="form-grid">
|
||||
<label className="form-field">
|
||||
@@ -106,7 +110,14 @@ export default function EmployeeForm({ form, onChange, includeStatus = false })
|
||||
|
||||
<label className="form-field">
|
||||
<span>Geburtsdatum</span>
|
||||
<input type="date" value={form.dateOfBirth} onChange={updateField("dateOfBirth")} />
|
||||
<div className="input-with-action">
|
||||
<input type="date" value={form.dateOfBirth} onChange={updateField("dateOfBirth")} />
|
||||
{form.dateOfBirth && (
|
||||
<OmsorgButton type="button" variant="secondary" onClick={clearField("dateOfBirth")}>
|
||||
Entfernen
|
||||
</OmsorgButton>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<fieldset className="form-field form-field--fieldset">
|
||||
@@ -147,12 +158,26 @@ export default function EmployeeForm({ form, onChange, includeStatus = false })
|
||||
|
||||
<label className="form-field">
|
||||
<span>Eintrittsdatum</span>
|
||||
<input type="date" value={form.entryDate} onChange={updateField("entryDate")} />
|
||||
<div className="input-with-action">
|
||||
<input type="date" value={form.entryDate} onChange={updateField("entryDate")} />
|
||||
{form.entryDate && (
|
||||
<OmsorgButton type="button" variant="secondary" onClick={clearField("entryDate")}>
|
||||
Entfernen
|
||||
</OmsorgButton>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="form-field">
|
||||
<span>Austrittsdatum</span>
|
||||
<input type="date" value={form.exitDate} onChange={updateField("exitDate")} />
|
||||
<div className="input-with-action">
|
||||
<input type="date" value={form.exitDate} onChange={updateField("exitDate")} />
|
||||
{form.exitDate && (
|
||||
<OmsorgButton type="button" variant="secondary" onClick={clearField("exitDate")}>
|
||||
Entfernen
|
||||
</OmsorgButton>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="form-field">
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import EmployeeFacilityDistanceForm, {
|
||||
emptyEmployeeFacilityDistanceForm,
|
||||
employeeFacilityDistanceFormToPayload,
|
||||
FormActions,
|
||||
} from "./EmployeeFacilityDistanceForm";
|
||||
import ModalPortal from "../../components/ui/ModalPortal";
|
||||
|
||||
function errorMessage(result) {
|
||||
if (result.status === 403) {
|
||||
return "Keine Berechtigung, Entfernungen anzulegen.";
|
||||
}
|
||||
if (result.status === 400) {
|
||||
return typeof result.data === "string" ? result.data : "Eingaben prüfen.";
|
||||
}
|
||||
return "Entfernung konnte nicht angelegt werden.";
|
||||
}
|
||||
|
||||
export default function CreateEmployeeFacilityDistanceDialog({ facilityId, onClose, onCreated }) {
|
||||
const [form, setForm] = useState(emptyEmployeeFacilityDistanceForm);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
async function handleSubmit(event) {
|
||||
event.preventDefault();
|
||||
|
||||
if (!form.employeeId) {
|
||||
setError("Mitarbeiter ist erforderlich.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
setError(null);
|
||||
|
||||
const result = await window.omsorg.employeeFacilityDistances.create(
|
||||
facilityId,
|
||||
employeeFacilityDistanceFormToPayload(form, { includeEmployeeId: true })
|
||||
);
|
||||
|
||||
setIsSaving(false);
|
||||
|
||||
if (!result.ok) {
|
||||
setError(errorMessage(result));
|
||||
return;
|
||||
}
|
||||
|
||||
onCreated(result.data);
|
||||
}
|
||||
|
||||
return (
|
||||
<ModalPortal>
|
||||
<div className="modal-overlay" role="dialog" aria-modal="true" aria-label="Neue Entfernung">
|
||||
<div className="modal-panel">
|
||||
<h2>Neue Entfernung</h2>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<EmployeeFacilityDistanceForm form={form} onChange={setForm} includeEmployeeSelect />
|
||||
|
||||
{error && <p className="login-error form-error">{error}</p>}
|
||||
|
||||
<FormActions onCancel={onClose} isSaving={isSaving} submitLabel="Speichern" />
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</ModalPortal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import EmployeeFacilityDistanceForm, {
|
||||
employeeFacilityDistanceFormToPayload,
|
||||
employeeFacilityDistanceToFormValues,
|
||||
FormActions,
|
||||
} from "./EmployeeFacilityDistanceForm";
|
||||
import ModalPortal from "../../components/ui/ModalPortal";
|
||||
|
||||
function errorMessage(result) {
|
||||
if (result.status === 403) {
|
||||
return "Keine Berechtigung, Entfernungen zu bearbeiten.";
|
||||
}
|
||||
if (result.status === 400) {
|
||||
return typeof result.data === "string" ? result.data : "Eingaben prüfen.";
|
||||
}
|
||||
return "Entfernung konnte nicht gespeichert werden.";
|
||||
}
|
||||
|
||||
export default function EditEmployeeFacilityDistanceDialog({ facilityId, distance, onClose, onUpdated }) {
|
||||
const [form, setForm] = useState(() => employeeFacilityDistanceToFormValues(distance));
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
async function handleSubmit(event) {
|
||||
event.preventDefault();
|
||||
|
||||
setIsSaving(true);
|
||||
setError(null);
|
||||
|
||||
const payload = employeeFacilityDistanceFormToPayload(form);
|
||||
const result = await window.omsorg.employeeFacilityDistances.update(facilityId, distance.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="Entfernung bearbeiten">
|
||||
<div className="modal-panel">
|
||||
<h2>
|
||||
{distance.employeeFirstName} {distance.employeeLastName} bearbeiten
|
||||
</h2>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<EmployeeFacilityDistanceForm 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,86 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import OmsorgButton from "../../components/ui/OmsorgButton";
|
||||
|
||||
export const emptyEmployeeFacilityDistanceForm = {
|
||||
employeeId: "",
|
||||
distanceKm: "",
|
||||
};
|
||||
|
||||
export function employeeFacilityDistanceToFormValues(distance) {
|
||||
return {
|
||||
employeeId: distance.employeeId ?? "",
|
||||
distanceKm: typeof distance.distanceKm === "number" ? String(distance.distanceKm) : "",
|
||||
};
|
||||
}
|
||||
|
||||
export function employeeFacilityDistanceFormToPayload(form, { includeEmployeeId = false } = {}) {
|
||||
const payload = {
|
||||
distanceKm: Number(form.distanceKm) || 0,
|
||||
};
|
||||
|
||||
if (includeEmployeeId) {
|
||||
payload.employeeId = form.employeeId;
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
export default function EmployeeFacilityDistanceForm({ form, onChange, includeEmployeeSelect = false }) {
|
||||
const [employees, setEmployees] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!includeEmployeeSelect) return;
|
||||
|
||||
let cancelled = false;
|
||||
window.omsorg.employees.list({ pageSize: 500 }).then((result) => {
|
||||
if (!cancelled && result.ok) {
|
||||
setEmployees(result.data?.items ?? []);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [includeEmployeeSelect]);
|
||||
|
||||
function updateField(field) {
|
||||
return (event) => onChange({ ...form, [field]: event.target.value });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="form-grid">
|
||||
{includeEmployeeSelect && (
|
||||
<label className="form-field">
|
||||
<span>Mitarbeiter *</span>
|
||||
<select value={form.employeeId} onChange={updateField("employeeId")} required>
|
||||
<option value="">— auswählen —</option>
|
||||
{employees.map((employee) => (
|
||||
<option key={employee.id} value={employee.id}>
|
||||
{employee.firstName} {employee.lastName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
|
||||
<label className="form-field">
|
||||
<span>Entfernung (km, einfache Strecke) *</span>
|
||||
<input type="number" step="0.1" min="0" value={form.distanceKm} onChange={updateField("distanceKm")} 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,132 @@
|
||||
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 CreateEmployeeFacilityDistanceDialog from "./CreateEmployeeFacilityDistanceDialog";
|
||||
import EditEmployeeFacilityDistanceDialog from "./EditEmployeeFacilityDistanceDialog";
|
||||
|
||||
export default function EmployeeFacilityDistancesList({ facilityId }) {
|
||||
const { hasPermission } = useAuth();
|
||||
const canCreate = hasPermission("Facilities", "Create");
|
||||
const canEdit = hasPermission("Facilities", "Edit");
|
||||
const canDelete = hasPermission("Facilities", "Delete");
|
||||
|
||||
const [distances, setDistances] = useState([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [editingDistance, setEditingDistance] = useState(null);
|
||||
const [deletingDistanceId, setDeletingDistanceId] = useState(null);
|
||||
|
||||
const loadDistances = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const result = await window.omsorg.employeeFacilityDistances.list(facilityId);
|
||||
|
||||
if (result.ok) {
|
||||
setDistances(result.data ?? []);
|
||||
} else {
|
||||
setError("Entfernungen konnten nicht geladen werden.");
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
}, [facilityId]);
|
||||
|
||||
useEffect(() => {
|
||||
loadDistances();
|
||||
}, [loadDistances]);
|
||||
|
||||
function handleCreated() {
|
||||
setIsDialogOpen(false);
|
||||
loadDistances();
|
||||
}
|
||||
|
||||
function handleUpdated() {
|
||||
setEditingDistance(null);
|
||||
loadDistances();
|
||||
}
|
||||
|
||||
async function handleDelete(distance) {
|
||||
if (!window.confirm(`Entfernung für "${distance.employeeFirstName} ${distance.employeeLastName}" wirklich löschen?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDeletingDistanceId(distance.id);
|
||||
const result = await window.omsorg.employeeFacilityDistances.delete(facilityId, distance.id);
|
||||
setDeletingDistanceId(null);
|
||||
|
||||
if (result.ok) {
|
||||
loadDistances();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="employee-tab-content">
|
||||
<div className="page-heading">
|
||||
<h3>Fahrtstrecken je Mitarbeiter</h3>
|
||||
|
||||
{canCreate && (
|
||||
<OmsorgButton icon={Plus} variant="secondary" onClick={() => setIsDialogOpen(true)}>
|
||||
Neue Entfernung
|
||||
</OmsorgButton>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <p className="login-error">{error}</p>}
|
||||
{isLoading && <p>Lädt...</p>}
|
||||
|
||||
{!isLoading && distances.length === 0 && <p>Noch keine Entfernung erfasst.</p>}
|
||||
|
||||
{!isLoading && distances.length > 0 && (
|
||||
<div className="facility-contacts-list">
|
||||
{distances.map((distance) => (
|
||||
<div key={distance.id} className="facility-contact-row">
|
||||
<div>
|
||||
<strong>
|
||||
{distance.employeeFirstName} {distance.employeeLastName}
|
||||
</strong>
|
||||
<p>{distance.distanceKm.toFixed(1)} km</p>
|
||||
</div>
|
||||
|
||||
{canEdit && (
|
||||
<OmsorgButton variant="secondary" icon={Pencil} onClick={() => setEditingDistance(distance)}>
|
||||
Bearbeiten
|
||||
</OmsorgButton>
|
||||
)}
|
||||
|
||||
{canDelete && (
|
||||
<OmsorgButton
|
||||
variant="secondary"
|
||||
icon={Trash2}
|
||||
onClick={() => handleDelete(distance)}
|
||||
disabled={deletingDistanceId === distance.id}
|
||||
>
|
||||
Löschen
|
||||
</OmsorgButton>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isDialogOpen && (
|
||||
<CreateEmployeeFacilityDistanceDialog
|
||||
facilityId={facilityId}
|
||||
onClose={() => setIsDialogOpen(false)}
|
||||
onCreated={handleCreated}
|
||||
/>
|
||||
)}
|
||||
|
||||
{editingDistance && (
|
||||
<EditEmployeeFacilityDistanceDialog
|
||||
facilityId={facilityId}
|
||||
distance={editingDistance}
|
||||
onClose={() => setEditingDistance(null)}
|
||||
onUpdated={handleUpdated}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { useAuth } from "../../app/AuthContext";
|
||||
import EditFacilityDialog from "./EditFacilityDialog";
|
||||
import FacilityContactsList from "./FacilityContactsList";
|
||||
import FacilityQualificationRatesList from "./FacilityQualificationRatesList";
|
||||
import EmployeeFacilityDistancesList from "./EmployeeFacilityDistancesList";
|
||||
|
||||
function formatFollowUpDueDate(followUpDueDate) {
|
||||
if (!followUpDueDate) {
|
||||
@@ -138,7 +139,15 @@ export default function FacilityDetailPanel({ facility, onUpdated }) {
|
||||
|
||||
<div>
|
||||
<strong>Fahrtkosten</strong>
|
||||
<p>{facility.travelCostRate != null ? `${facility.travelCostRate} EUR (Pauschale je Einsatz)` : "—"}</p>
|
||||
<p>
|
||||
{facility.travelCostMode === "ProKilometer"
|
||||
? facility.travelCostPerKm != null
|
||||
? `${facility.travelCostPerKm} EUR/km`
|
||||
: "—"
|
||||
: facility.travelCostRate != null
|
||||
? `${facility.travelCostRate} EUR (Pauschale je Einsatz)`
|
||||
: "—"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -168,6 +177,7 @@ export default function FacilityDetailPanel({ facility, onUpdated }) {
|
||||
</div>
|
||||
|
||||
<FacilityQualificationRatesList facilityId={facility.id} />
|
||||
{facility.travelCostMode === "ProKilometer" && <EmployeeFacilityDistancesList facilityId={facility.id} />}
|
||||
<FacilityContactsList facilityId={facility.id} />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -21,7 +21,9 @@ export const emptyFacilityForm = {
|
||||
saturdaySurchargePercent: "",
|
||||
sundaySurchargePercent: "",
|
||||
holidaySurchargePercent: "",
|
||||
travelCostMode: "Pauschale",
|
||||
travelCostRate: "",
|
||||
travelCostPerKm: "",
|
||||
minimumHours: "",
|
||||
breakPolicy: "",
|
||||
billingInterval: "",
|
||||
@@ -48,7 +50,9 @@ export function facilityToFormValues(facility) {
|
||||
saturdaySurchargePercent: facility.saturdaySurchargePercent ?? "",
|
||||
sundaySurchargePercent: facility.sundaySurchargePercent ?? "",
|
||||
holidaySurchargePercent: facility.holidaySurchargePercent ?? "",
|
||||
travelCostMode: facility.travelCostMode ?? "Pauschale",
|
||||
travelCostRate: facility.travelCostRate ?? "",
|
||||
travelCostPerKm: facility.travelCostPerKm ?? "",
|
||||
minimumHours: facility.minimumHours ?? "",
|
||||
breakPolicy: facility.breakPolicy ?? "",
|
||||
billingInterval: facility.billingInterval ?? "",
|
||||
@@ -80,7 +84,9 @@ export function facilityFormToPayload(form, { includeCrmStatus = false, followUp
|
||||
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.travelCostMode = form.travelCostMode || "Pauschale";
|
||||
payload.travelCostRate = form.travelCostRate === "" ? null : Number(form.travelCostRate);
|
||||
payload.travelCostPerKm = form.travelCostPerKm === "" ? null : Number(form.travelCostPerKm);
|
||||
payload.minimumHours = form.minimumHours === "" ? null : Number(form.minimumHours);
|
||||
payload.breakPolicy = form.breakPolicy.trim() || null;
|
||||
payload.billingInterval = form.billingInterval || null;
|
||||
@@ -249,8 +255,11 @@ export default function FacilityForm({ form, onChange, includeCrmStatus = false,
|
||||
|
||||
<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")} />
|
||||
<span>Fahrtkosten — Abrechnungsart</span>
|
||||
<select value={form.travelCostMode} onChange={updateField("travelCostMode")}>
|
||||
<option value="Pauschale">Pauschale je Einsatz</option>
|
||||
<option value="ProKilometer">Pro Kilometer</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="form-field">
|
||||
@@ -259,6 +268,20 @@ export default function FacilityForm({ form, onChange, includeCrmStatus = false,
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
{form.travelCostMode === "ProKilometer" ? (
|
||||
<label className="form-field">
|
||||
<span>Fahrtkosten (EUR/km)</span>
|
||||
<input type="number" min="0" step="0.01" value={form.travelCostPerKm} onChange={updateField("travelCostPerKm")} />
|
||||
</label>
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<label className="form-field">
|
||||
<span>Pausenregelung</span>
|
||||
<input value={form.breakPolicy} onChange={updateField("breakPolicy")} />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Plus } from "lucide-react";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import OmsorgButton from "../../components/ui/OmsorgButton";
|
||||
import RolePermissionMatrix from "./RolePermissionMatrix";
|
||||
import { useAuth } from "../../app/AuthContext";
|
||||
@@ -8,6 +8,7 @@ export default function RolesPanel() {
|
||||
const { hasPermission } = useAuth();
|
||||
const canCreate = hasPermission("UserManagement", "Create");
|
||||
const canEdit = hasPermission("UserManagement", "Edit");
|
||||
const canDelete = hasPermission("UserManagement", "Delete");
|
||||
|
||||
const [roles, setRoles] = useState([]);
|
||||
const [selectedRoleId, setSelectedRoleId] = useState(null);
|
||||
@@ -16,6 +17,8 @@ export default function RolesPanel() {
|
||||
const [newRoleName, setNewRoleName] = useState("");
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [createError, setCreateError] = useState(null);
|
||||
const [deletingRoleId, setDeletingRoleId] = useState(null);
|
||||
const [deleteError, setDeleteError] = useState(null);
|
||||
|
||||
const loadRoles = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
@@ -57,6 +60,33 @@ export default function RolesPanel() {
|
||||
setSelectedRoleId(result.data.id);
|
||||
}
|
||||
|
||||
async function handleDeleteRole(role) {
|
||||
if (!window.confirm(`Rolle "${role.name}" wirklich löschen?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDeletingRoleId(role.id);
|
||||
setDeleteError(null);
|
||||
const result = await window.omsorg.roles.delete(role.id);
|
||||
setDeletingRoleId(null);
|
||||
|
||||
if (!result.ok) {
|
||||
setDeleteError(
|
||||
result.status === 409
|
||||
? typeof result.data === "string"
|
||||
? result.data
|
||||
: "Diese Rolle ist noch Benutzern zugewiesen und kann nicht gelöscht werden."
|
||||
: "Rolle konnte nicht gelöscht werden."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedRoleId === role.id) {
|
||||
setSelectedRoleId(null);
|
||||
}
|
||||
await loadRoles();
|
||||
}
|
||||
|
||||
const selectedRole = roles.find((role) => role.id === selectedRoleId);
|
||||
|
||||
return (
|
||||
@@ -79,6 +109,7 @@ export default function RolesPanel() {
|
||||
)}
|
||||
</div>
|
||||
{createError && <p className="login-error">{createError}</p>}
|
||||
{deleteError && <p className="login-error">{deleteError}</p>}
|
||||
|
||||
{isLoading && <p>Lädt...</p>}
|
||||
{error && <p className="login-error">{error}</p>}
|
||||
@@ -88,7 +119,7 @@ export default function RolesPanel() {
|
||||
<div className="settings-roles-list">
|
||||
<ul>
|
||||
{roles.map((role) => (
|
||||
<li key={role.id}>
|
||||
<li key={role.id} className="settings-role-item-row">
|
||||
<button
|
||||
type="button"
|
||||
className={role.id === selectedRoleId ? "settings-role-item settings-role-item--active" : "settings-role-item"}
|
||||
@@ -96,6 +127,15 @@ export default function RolesPanel() {
|
||||
>
|
||||
{role.name}
|
||||
</button>
|
||||
|
||||
{canDelete && (
|
||||
<OmsorgButton
|
||||
variant="secondary"
|
||||
icon={Trash2}
|
||||
onClick={() => handleDeleteRole(role)}
|
||||
disabled={deletingRoleId === role.id}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -59,6 +59,14 @@ const TABS = [
|
||||
restore: (id) => window.omsorg.trash.restoreFacilityQualificationRate(id),
|
||||
displayName: (item) => item.qualification,
|
||||
},
|
||||
{
|
||||
key: "employeeFacilityDistances",
|
||||
label: "Entfernungen",
|
||||
module: "Facilities",
|
||||
list: (search) => window.omsorg.trash.listEmployeeFacilityDistances(search),
|
||||
restore: (id) => window.omsorg.trash.restoreEmployeeFacilityDistance(id),
|
||||
displayName: (item) => `${item.distanceKm} km`,
|
||||
},
|
||||
{
|
||||
key: "absences",
|
||||
label: "Abwesenheiten",
|
||||
|
||||
@@ -931,6 +931,16 @@ button.omsorg-button--danger {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.input-with-action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.input-with-action input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.form-error {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
@@ -1788,6 +1798,21 @@ button.omsorg-button--danger {
|
||||
border-color: var(--omsorg-primary, currentColor);
|
||||
}
|
||||
|
||||
.settings-role-item-row {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.settings-role-item-row .settings-role-item {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.settings-role-item-row .omsorg-button span:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.settings-new-role-form {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
||||
Reference in New Issue
Block a user