Einrichtung: Konditionen und CRM-Status beim Anlegen direkt sichtbar machen

- Backend (omsorgCore): CreateFacilityRequest um Konditionen- und CRM-Status-Felder erweitert, identisch mit UpdateFacilityRequest
- FacilitiesController.Create: Gleiche Validierung wie Update (CrmStatus-Allowlist, FollowUp-Logik, Konditions-Wertebereich), aber ohne Transition-Check (kein Vorzustand beim Anlegen)
- Gemeinsame Validierungslogik in ValidateCrmStatusAndFollowUpAsync extrahiert, um Code-Duplizierung zu vermeiden
- omsorgCore/CLAUDE.md aktualisiert: Konditionen sind jetzt auch beim Anlegen setzbar
- API-Clients neu generiert (TypeScript und PHP)
- Frontend (omsorgapp): FacilityForm.jsx: includeCrmStatus-Flag entfernt, Konditionen und CRM-Status immer sichtbar
- CreateFacilityDialog.jsx: CRM-Status-Auswahl + FollowUpDaysDialog-Workflow (analog zu Edit-Dialog)
- EditFacilityDialog.jsx: vereinfacht (includeCrmStatus-Prop entfernt)

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
Felix Kemmler
2026-08-10 22:33:27 +02:00
co-authored by Claude Haiku 4.5
parent 52fbec3214
commit e1fbfadd17
9 changed files with 842 additions and 90 deletions
@@ -2,6 +2,7 @@ namespace OmsorgCore.Api.Contracts;
public record CreateFacilityRequest(
string Name,
string CrmStatus,
string? FacilityType,
string? Website,
string? Street,
@@ -11,4 +12,18 @@ public record CreateFacilityRequest(
string? BillingStreet,
string? BillingPostalCode,
string? BillingCity,
string? BillingCountry);
string? BillingCountry,
int? FollowUpDays,
decimal? BillingRate,
decimal? NightSurchargePercent,
decimal? SaturdaySurchargePercent,
decimal? SundaySurchargePercent,
decimal? HolidaySurchargePercent,
decimal? TravelCostRate,
string TravelCostMode,
decimal? TravelCostPerKm,
decimal? MealAllowanceRate,
decimal? MinimumHours,
string? BillingInterval,
int? PaymentTermDays,
string? IndividualAgreements);
@@ -15,6 +15,8 @@ namespace OmsorgCore.Api.Controllers;
[Route("api/facilities")]
public class FacilitiesController : ControllerBase
{
private record CrmStatusValidationResult(string? ErrorMessage, DateTime? FollowUpDueDate);
private const string CrmStatusListKey = "CrmStatus";
private const string FacilityTypeListKey = "FacilityType";
private const string FollowUpPeriodsListKey = "FollowUpPeriods";
@@ -66,6 +68,11 @@ public class FacilitiesController : ControllerBase
return BadRequest("Name ist erforderlich und darf maximal 300 Zeichen lang sein.");
}
if (string.IsNullOrWhiteSpace(request.CrmStatus) || request.CrmStatus.Length > 50)
{
return BadRequest("CrmStatus ist erforderlich und darf maximal 50 Zeichen lang sein.");
}
if (request.FacilityType is { Length: > 100 })
{
return BadRequest("FacilityType darf maximal 100 Zeichen lang sein.");
@@ -97,9 +104,67 @@ public class FacilitiesController : ControllerBase
return BadRequest(billingAddressError);
}
var crmStatusValidationError = await ValidateCrmStatusAndFollowUpAsync(
request.CrmStatus,
request.FollowUpDays,
existingCrmStatus: null,
cancellationToken);
if (crmStatusValidationError.ErrorMessage is not null)
{
return BadRequest(crmStatusValidationError.ErrorMessage);
}
if (request.IndividualAgreements is { Length: > 2000 })
{
return BadRequest("IndividualAgreements darf maximal 2000 Zeichen lang sein.");
}
if (request.BillingInterval is not null)
{
var allowedBillingIntervals = await _valueListRepository.GetActiveValuesAsync(BillingIntervalListKey, cancellationToken);
if (!allowedBillingIntervals.Contains(request.BillingInterval))
{
return BadRequest($"BillingInterval muss einer der folgenden Werte sein: {string.Join(", ", allowedBillingIntervals)}.");
}
}
if (request.BillingRate is < 0
|| request.TravelCostRate is < 0
|| request.TravelCostPerKm is < 0
|| request.MealAllowanceRate is < 0
|| request.MinimumHours is < 0
|| request.NightSurchargePercent is < 0
|| request.SaturdaySurchargePercent is < 0
|| request.SundaySurchargePercent is < 0
|| request.HolidaySurchargePercent is < 0
|| request.PaymentTermDays is < 0)
{
return BadRequest("Konditionswerte dürfen nicht negativ sein.");
}
if (!TravelCostModes.Contains(request.TravelCostMode))
{
return BadRequest($"TravelCostMode muss einer der folgenden Werte sein: {string.Join(", ", TravelCostModes)}.");
}
var facility = new Facility
{
Name = request.Name,
CrmStatus = request.CrmStatus,
FollowUpDueDate = crmStatusValidationError.FollowUpDueDate,
BillingRate = request.BillingRate,
NightSurchargePercent = request.NightSurchargePercent,
SaturdaySurchargePercent = request.SaturdaySurchargePercent,
SundaySurchargePercent = request.SundaySurchargePercent,
HolidaySurchargePercent = request.HolidaySurchargePercent,
TravelCostRate = request.TravelCostRate,
TravelCostMode = request.TravelCostMode,
TravelCostPerKm = request.TravelCostPerKm,
MealAllowanceRate = request.MealAllowanceRate,
MinimumHours = request.MinimumHours,
BillingInterval = request.BillingInterval,
PaymentTermDays = request.PaymentTermDays,
IndividualAgreements = request.IndividualAgreements,
FacilityType = request.FacilityType,
Website = request.Website,
Street = request.Street,
@@ -138,37 +203,20 @@ public class FacilitiesController : ControllerBase
return BadRequest("CrmStatus ist erforderlich und darf maximal 50 Zeichen lang sein.");
}
var crmStatusItems = await _valueListRepository.GetItemsAsync(CrmStatusListKey, cancellationToken);
var selectedCrmStatusItem = crmStatusItems.FirstOrDefault(i => i.Value == request.CrmStatus);
if (selectedCrmStatusItem is null)
var crmStatusValidationError = await ValidateCrmStatusAndFollowUpAsync(
request.CrmStatus,
request.FollowUpDays,
existingCrmStatus: existing.CrmStatus,
cancellationToken);
if (crmStatusValidationError.ErrorMessage is not null)
{
return BadRequest($"CrmStatus muss einer der folgenden Werte sein: {string.Join(", ", crmStatusItems.Select(i => i.Value))}.");
return BadRequest(crmStatusValidationError.ErrorMessage);
}
var currentCrmStatusItem = crmStatusItems.FirstOrDefault(i => i.Value == existing.CrmStatus);
if (currentCrmStatusItem is not null
&& !await _valueListRepository.CanTransitionAsync(currentCrmStatusItem.Id, selectedCrmStatusItem.Id, cancellationToken))
var followUpDueDate = crmStatusValidationError.FollowUpDueDate;
if (followUpDueDate is null && existing.CrmStatus == request.CrmStatus)
{
return BadRequest("Der Statuswechsel ist nicht zulässig.");
}
DateTime? followUpDueDate = null;
if (selectedCrmStatusItem.TriggersFollowUp)
{
if (existing.CrmStatus == request.CrmStatus)
{
followUpDueDate = existing.FollowUpDueDate;
}
else
{
var allowedFollowUpPeriods = await _valueListRepository.GetActiveValuesAsync(FollowUpPeriodsListKey, cancellationToken);
if (request.FollowUpDays is null || !allowedFollowUpPeriods.Contains(request.FollowUpDays.Value.ToString()))
{
return BadRequest($"FollowUpDays ist bei CrmStatus \"{request.CrmStatus}\" erforderlich und muss einer der folgenden Werte sein: {string.Join(", ", allowedFollowUpPeriods)}.");
}
followUpDueDate = DateTime.UtcNow.AddDays(request.FollowUpDays.Value);
}
followUpDueDate = existing.FollowUpDueDate;
}
if (request.FacilityType is { Length: > 100 })
@@ -277,6 +325,51 @@ public class FacilitiesController : ControllerBase
return deleted ? NoContent() : NotFound();
}
private async Task<CrmStatusValidationResult> ValidateCrmStatusAndFollowUpAsync(
string crmStatus,
int? followUpDays,
string? existingCrmStatus,
CancellationToken cancellationToken)
{
var crmStatusItems = await _valueListRepository.GetItemsAsync(CrmStatusListKey, cancellationToken);
var selectedCrmStatusItem = crmStatusItems.FirstOrDefault(i => i.Value == crmStatus);
if (selectedCrmStatusItem is null)
{
return new($"CrmStatus muss einer der folgenden Werte sein: {string.Join(", ", crmStatusItems.Select(i => i.Value))}.", null);
}
if (existingCrmStatus is not null)
{
var currentCrmStatusItem = crmStatusItems.FirstOrDefault(i => i.Value == existingCrmStatus);
if (currentCrmStatusItem is not null
&& !await _valueListRepository.CanTransitionAsync(currentCrmStatusItem.Id, selectedCrmStatusItem.Id, cancellationToken))
{
return new("Der Statuswechsel ist nicht zulässig.", null);
}
}
DateTime? followUpDueDate = null;
if (selectedCrmStatusItem.TriggersFollowUp)
{
if (existingCrmStatus is not null && existingCrmStatus == crmStatus)
{
followUpDueDate = null;
}
else
{
var allowedFollowUpPeriods = await _valueListRepository.GetActiveValuesAsync(FollowUpPeriodsListKey, cancellationToken);
if (followUpDays is null || !allowedFollowUpPeriods.Contains(followUpDays.Value.ToString()))
{
return new($"FollowUpDays ist bei CrmStatus \"{crmStatus}\" erforderlich und muss einer der folgenden Werte sein: {string.Join(", ", allowedFollowUpPeriods)}.", null);
}
followUpDueDate = DateTime.UtcNow.AddDays(followUpDays.Value);
}
}
return new(null, followUpDueDate);
}
private static string? ValidateAddressFields(string? street, string? postalCode, string? city, string? country, string prefix)
{
if (street is { Length: > 200 })