Эх сурвалжийг харах

hhm-einkommen verschoben um weitere Seiten zu erstellen

Leonard Schulz 1 жил өмнө
parent
commit
50a1ea8b30

+ 1 - 1
src/main/java/com/tsystems/inwo/vgl/service/DataComparisonService.java

@@ -3,7 +3,7 @@ package com.tsystems.inwo.vgl.service;
 import com.fasterxml.jackson.databind.JsonNode;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import com.fasterxml.jackson.databind.node.ArrayNode;
-import com.tsystems.inwo.vgl.web.VergleichController.Pair;
+import com.tsystems.inwo.vgl.view.SharedRecords.Pair;
 import org.springframework.stereotype.Service;
 
 import java.util.LinkedHashMap;

+ 5 - 5
src/main/java/com/tsystems/inwo/vgl/service/JsonNavigationService.java

@@ -5,11 +5,11 @@ import com.fasterxml.jackson.databind.ObjectMapper;
 import com.fasterxml.jackson.databind.node.ArrayNode;
 import com.fasterxml.jackson.databind.node.JsonNodeFactory;
 import com.fasterxml.jackson.databind.node.NullNode;
-import com.tsystems.inwo.vgl.web.VergleichController.Z;
-import com.tsystems.inwo.vgl.web.VergleichController.Vorgang;
-import com.tsystems.inwo.vgl.web.VergleichController.PersonRef;
-import com.tsystems.inwo.vgl.web.VergleichController.StammvorgangInfo;
-import com.tsystems.inwo.vgl.web.VergleichController.StammakteInfo;
+import com.tsystems.inwo.vgl.view.SharedRecords.Z;
+import com.tsystems.inwo.vgl.view.SharedRecords.Vorgang;
+import com.tsystems.inwo.vgl.view.SharedRecords.PersonRef;
+import com.tsystems.inwo.vgl.view.SharedRecords.StammvorgangInfo;
+import com.tsystems.inwo.vgl.view.SharedRecords.StammakteInfo;
 import com.tsystems.inwo.vgl.util.FormatUtil;
 import org.springframework.stereotype.Service;
 

+ 1 - 1
src/main/java/com/tsystems/inwo/vgl/service/VergleichDataExtractor.java

@@ -7,7 +7,7 @@ import com.tsystems.inwo.vgl.view.EinkommenZeile;
 import com.tsystems.inwo.vgl.view.FreibetragZeile;
 import com.tsystems.inwo.vgl.view.KitaZeile;
 import com.tsystems.inwo.vgl.view.MitgliedRow;
-import com.tsystems.inwo.vgl.web.VergleichController.Pair;
+import com.tsystems.inwo.vgl.view.SharedRecords.Pair;
 import com.tsystems.inwo.vgl.util.FormatUtil;
 import org.springframework.stereotype.Service;
 

+ 32 - 0
src/main/java/com/tsystems/inwo/vgl/view/SharedRecords.java

@@ -0,0 +1,32 @@
+package com.tsystems.inwo.vgl.view;
+
+import java.util.List;
+
+public class SharedRecords {
+    
+    public static record Z(String id, String beginn, String ende) {}
+    
+    public static record Vorgang(String id, String vorgangstyp, List<Z> zeitraeume) {}
+    
+    public static record PersonRef(String id, String label) {}
+    
+    public static record StammvorgangInfo(String id, String vorgangstyp, String vorgangsstatus) {}
+    
+    public static record StammakteInfo(String aktenzeichen, String antragstellername, String antragstellerGeburtsdatum, String anschrift) {}
+    
+    public static record PersonDetailsResponse(String personId, String personLabel, List<EinkommenZeile> einkommenZeilen, List<FreibetragZeile> freibetragZeilen, List<KitaZeile> kitaZeilen) {}
+    
+    public static record VorgangChangeResponse(String leftVorgangId, String leftZeitraumId, List<Z> leftZeitraeume, List<MitgliedRow> mitgliederRows, String personId, String personLabel, List<EinkommenZeile> einkommenZeilen, List<FreibetragZeile> freibetragZeilen, List<KitaZeile> kitaZeilen) {}
+    
+    public static record ZeitraumChangeResponse(String leftZeitraumId, List<MitgliedRow> mitgliederRows, String personId, String personLabel, List<EinkommenZeile> einkommenZeilen, List<FreibetragZeile> freibetragZeilen, List<KitaZeile> kitaZeilen) {}
+    
+    public static final class Pair<R, L> {
+        public final R right;
+        public final L left;
+        
+        public Pair(R r, L l) {
+            right = r;
+            left = l;
+        }
+    }
+}

+ 316 - 0
src/main/java/com/tsystems/inwo/vgl/web/HhmEinkommenController.java

@@ -0,0 +1,316 @@
+package com.tsystems.inwo.vgl.web;
+
+import com.fasterxml.jackson.databind.*;
+import com.tsystems.inwo.vgl.service.*;
+import com.tsystems.inwo.vgl.view.*;
+import com.tsystems.inwo.vgl.view.SharedRecords.*;
+import org.springframework.stereotype.Controller;
+import org.springframework.ui.Model;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.http.ResponseEntity;
+import java.util.*;
+import java.util.stream.Collectors;
+
+@Controller
+@RequestMapping("/hhm-einkommen")
+public class HhmEinkommenController {
+    private final ObjectMapper om;
+    private final SchemaValidationService validator;
+    private final JsonNavigationService jsonNavigationService;
+    private final DataComparisonService comparisonService;
+    private final VergleichDataExtractor dataExtractor;
+    
+    public HhmEinkommenController(ObjectMapper om, SchemaValidationService validator, 
+                              JsonNavigationService jsonNavigationService,
+                              DataComparisonService comparisonService,
+                              VergleichDataExtractor dataExtractor) {
+        this.om = om;
+        this.validator = validator;
+        this.jsonNavigationService = jsonNavigationService;
+        this.comparisonService = comparisonService;
+        this.dataExtractor = dataExtractor;
+    }
+
+    @PostMapping
+    public String vergleich(
+            @RequestParam String leftJson,
+            @RequestParam String rightJson,
+            @RequestParam(required = false, defaultValue = "true") boolean validate,
+            @RequestParam(required = false, defaultValue = "haushalt-einkommen") String schemaName,
+            @RequestParam(required = false) String leftVorgangId,
+            @RequestParam(required = false) String leftZeitraumId,
+            @RequestParam(required = false) String rightZeitraumId,
+            @RequestParam(required = false) String personId,
+            @RequestParam(required = false) String kunde,
+            @RequestHeader(value = "X-Kunde", required = false) String kundeHeader,
+            Model model) throws Exception {
+
+        String activeKunde = resolveActiveKunde(kunde, kundeHeader);
+
+        JsonNode left = om.readTree(leftJson);
+        JsonNode right = om.readTree(rightJson);
+
+        if (validate) {
+            List<String> err = new ArrayList<>();
+            err.addAll(validator.validate(left, schemaName));
+            err.addAll(validator.validate(right, schemaName));
+            if (!err.isEmpty()) {
+                model.addAttribute("schemaErrors", err);
+                model.addAttribute("leftJson", leftJson);
+                model.addAttribute("rightJson", rightJson);
+                model.addAttribute("validate", validate);
+                model.addAttribute("schemaName", schemaName);
+                return "test";
+            }
+        }
+
+        // --- Rechts (Master) Zeitraum wählen ---
+        List<Z> zeitrechte = jsonNavigationService.extractZeitraeume(right);
+        if (zeitrechte.isEmpty()) throw new IllegalArgumentException("Right JSON hat keine Zeiträume");
+        if (rightZeitraumId == null) rightZeitraumId = zeitrechte.get(0).id();
+        JsonNode rightZeitraum = jsonNavigationService.findZeitraumById(right, rightZeitraumId);
+
+        // --- Find the stammvorgang that contains the selected zeitraum ---
+        JsonNode stammvorgangJsonNode = jsonNavigationService.findStammvorgangByZeitraumId(right, rightZeitraumId);
+        StammvorgangInfo stammvorgangRight = jsonNavigationService.createStammvorgangInfo(stammvorgangJsonNode);
+
+        // --- Extract stammakte info if available ---
+        StammakteInfo stammakteRight = jsonNavigationService.extractStammakteInfo(right);
+
+        // --- Links (Baseline) Kandidaten & Auswahl ---
+        List<Vorgang> leftCand = jsonNavigationService.parseLeftCandidates(left);
+        List<Z> leftZ = List.of();
+        JsonNode leftZeitraum = null;
+        if (!leftCand.isEmpty()) {
+            if (leftVorgangId == null) leftVorgangId = leftCand.get(0).id();
+            final String finalLeftVorgangId = leftVorgangId;
+            leftZ = leftCand.stream().filter(v -> v.id().equals(finalLeftVorgangId)).findFirst().orElse(leftCand.get(0)).zeitraeume();
+            if (leftZeitraumId == null && !leftZ.isEmpty()) leftZeitraumId = leftZ.get(0).id();
+            if (leftZeitraumId != null) leftZeitraum = jsonNavigationService.findZeitraumById(jsonNavigationService.getRootOfVorgang(left, leftVorgangId), leftZeitraumId);
+        }
+
+        // --- Personenliste rechts + Default personId ---
+        List<PersonRef> personenRight = jsonNavigationService.listRightPersons(rightZeitraum);
+        if (personId == null && !personenRight.isEmpty()) personId = personenRight.get(0).id();
+
+        // --- Personen-Tabelle (UNION via person.id) ---
+        PersonComparisonContext context = createPersonComparisonContext(rightZeitraum, leftZeitraum, personId);
+        List<MitgliedRow> mitgliederRows = context.personenPairs.entrySet().stream()
+                .map(e -> dataExtractor.buildMitgliedRow(e.getKey(), e.getValue().right, e.getValue().left))
+                .sorted(Comparator.comparing(MitgliedRow::personId))
+                .toList();
+
+        // --- Model füllen & rendern ---
+        model.addAttribute("leftJson", leftJson);
+        model.addAttribute("rightJson", rightJson);
+        model.addAttribute("validate", validate);
+        model.addAttribute("schemaName", schemaName);
+        model.addAttribute("leftCandidates", leftCand);
+        model.addAttribute("leftZeitraeumeOfSelected", leftZ);
+        model.addAttribute("leftVorgangId", leftVorgangId);
+        model.addAttribute("leftZeitraumId", leftZeitraumId);
+        model.addAttribute("zeitraeumeRight", zeitrechte);
+        model.addAttribute("rightZeitraumId", rightZeitraumId);
+        model.addAttribute("stammvorgangRight", stammvorgangRight);
+        model.addAttribute("stammakteRight", stammakteRight);
+        model.addAttribute("personenRight", personenRight);
+        model.addAttribute("personId", personId);
+        model.addAttribute("mitgliederRows", mitgliederRows);
+        model.addAttribute("personLabel", context.personLabel);
+        model.addAttribute("einkommenZeilen", context.einkommenZeilen);
+        model.addAttribute("freibetragZeilen", context.freibetragZeilen);
+        model.addAttribute("kitaZeilen", context.kitaZeilen);
+        model.addAttribute("activeKunde", activeKunde);
+        return "hhm-einkommen";
+    }
+
+    @PostMapping("/api/person-details")
+    @ResponseBody
+    public ResponseEntity<PersonDetailsResponse> getPersonDetails(
+            @RequestParam String leftJson,
+            @RequestParam String rightJson,
+            @RequestParam String leftVorgangId,
+            @RequestParam String leftZeitraumId,
+            @RequestParam String rightZeitraumId,
+            @RequestParam String personId) throws Exception {
+
+        JsonNode left = om.readTree(leftJson);
+        JsonNode right = om.readTree(rightJson);
+
+        JsonNode rightZeitraum = jsonNavigationService.findZeitraumById(right, rightZeitraumId);
+        JsonNode leftZeitraum = null;
+        if (leftZeitraumId != null) {
+            leftZeitraum = jsonNavigationService.findZeitraumById(jsonNavigationService.getRootOfVorgang(left, leftVorgangId), leftZeitraumId);
+        }
+
+        PersonComparisonContext context = createPersonComparisonContext(rightZeitraum, leftZeitraum, personId);
+
+        PersonDetailsResponse response = new PersonDetailsResponse(
+                personId,
+                context.personLabel,
+                context.einkommenZeilen,
+                context.freibetragZeilen,
+                context.kitaZeilen
+        );
+
+        return ResponseEntity.ok(response);
+    }
+
+    @PostMapping("/api/vorgang-change")
+    @ResponseBody
+    public ResponseEntity<VorgangChangeResponse> changeLeftVorgang(
+            @RequestParam String leftJson,
+            @RequestParam String rightJson,
+            @RequestParam String rightZeitraumId,
+            @RequestParam String personId,
+            @RequestParam String newLeftVorgangId) throws Exception {
+
+        JsonNode left = om.readTree(leftJson);
+        JsonNode right = om.readTree(rightJson);
+
+        // --- Links (Baseline) Kandidaten & neue Auswahl ---
+        List<Vorgang> leftCand = jsonNavigationService.parseLeftCandidates(left);
+        
+        final String finalLeftVorgangId = newLeftVorgangId;
+        List<Z> leftZ = leftCand.stream()
+                .filter(v -> v.id().equals(finalLeftVorgangId))
+                .findFirst()
+                .orElse(leftCand.get(0))
+                .zeitraeume();
+        
+        // Ersten Zeitraum des neuen Vorgangs als Default wählen
+        String newLeftZeitraumId = !leftZ.isEmpty() ? leftZ.get(0).id() : null;
+        
+        JsonNode rightZeitraum = jsonNavigationService.findZeitraumById(right, rightZeitraumId);
+        JsonNode leftZeitraum = null;
+        if (newLeftZeitraumId != null) {
+            leftZeitraum = jsonNavigationService.findZeitraumById(jsonNavigationService.getRootOfVorgang(left, newLeftVorgangId), newLeftZeitraumId);
+        }
+
+        PersonComparisonContext context = createPersonComparisonContext(rightZeitraum, leftZeitraum, personId);
+        List<MitgliedRow> mitgliederRows = context.personenPairs.entrySet().stream()
+                .map(e -> dataExtractor.buildMitgliedRow(e.getKey(), e.getValue().right, e.getValue().left))
+                .sorted(Comparator.comparing(MitgliedRow::personId))
+                .toList();
+
+        VorgangChangeResponse response = new VorgangChangeResponse(
+                newLeftVorgangId,
+                newLeftZeitraumId,
+                leftZ,
+                mitgliederRows,
+                personId,
+                context.personLabel,
+                context.einkommenZeilen,
+                context.freibetragZeilen,
+                context.kitaZeilen
+        );
+
+        return ResponseEntity.ok(response);
+    }
+
+    @PostMapping("/api/zeitraum-change")
+    @ResponseBody
+    public ResponseEntity<ZeitraumChangeResponse> changeLeftZeitraum(
+            @RequestParam String leftJson,
+            @RequestParam String rightJson,
+            @RequestParam String leftVorgangId,
+            @RequestParam String rightZeitraumId,
+            @RequestParam String personId,
+            @RequestParam String newLeftZeitraumId) throws Exception {
+
+        JsonNode left = om.readTree(leftJson);
+        JsonNode right = om.readTree(rightJson);
+
+        JsonNode rightZeitraum = jsonNavigationService.findZeitraumById(right, rightZeitraumId);
+        JsonNode leftZeitraum = null;
+        if (newLeftZeitraumId != null) {
+            leftZeitraum = jsonNavigationService.findZeitraumById(jsonNavigationService.getRootOfVorgang(left, leftVorgangId), newLeftZeitraumId);
+        }
+
+        PersonComparisonContext context = createPersonComparisonContext(rightZeitraum, leftZeitraum, personId);
+        List<MitgliedRow> mitgliederRows = context.personenPairs.entrySet().stream()
+                .map(e -> dataExtractor.buildMitgliedRow(e.getKey(), e.getValue().right, e.getValue().left))
+                .sorted(Comparator.comparing(MitgliedRow::personId))
+                .toList();
+
+        ZeitraumChangeResponse response = new ZeitraumChangeResponse(
+                newLeftZeitraumId,
+                mitgliederRows,
+                personId,
+                context.personLabel,
+                context.einkommenZeilen,
+                context.freibetragZeilen,
+                context.kitaZeilen
+        );
+
+        return ResponseEntity.ok(response);
+    }
+
+    // ===== Private Helper Methods =====
+    
+    private String resolveActiveKunde(String kunde, String kundeHeader) {
+        return kunde != null ? kunde : kundeHeader;
+    }
+    
+    private PersonComparisonContext createPersonComparisonContext(JsonNode rightZeitraum, JsonNode leftZeitraum, String personId) {
+        // Personen-Tabelle (UNION via person.id)
+        Map<String, Pair<JsonNode, JsonNode>> personenPairs = comparisonService.unionByKey(
+                jsonNavigationService.personsArray(rightZeitraum), 
+                jsonNavigationService.personsArray(leftZeitraum), 
+                n -> n.path("person").path("id").asText()
+        );
+
+        // Details für ausgewählte Person
+        JsonNode rightPerson = personenPairs.getOrDefault(personId, new Pair<>(null, null)).right;
+        JsonNode leftPerson = personenPairs.getOrDefault(personId, new Pair<>(null, null)).left;
+
+        String personLabel = createPersonLabel(rightPerson, leftPerson);
+        Map<String, String> personNameById = createPersonNameMap(rightZeitraum, leftZeitraum);
+
+        // Einkommen / Freibeträge / Kita: jeweils UNION per id
+        List<EinkommenZeile> einkommenZeilen = dataExtractor.buildEinkommenZeilen(rightPerson, leftPerson);
+        List<FreibetragZeile> freibetragZeilen = dataExtractor.buildFreibetragZeilen(rightPerson, leftPerson);
+        List<KitaZeile> kitaZeilen = dataExtractor.buildKitaZeilen(rightPerson, leftPerson, personNameById);
+
+        return new PersonComparisonContext(personenPairs, personLabel, einkommenZeilen, freibetragZeilen, kitaZeilen);
+    }
+    
+    private String createPersonLabel(JsonNode rightPerson, JsonNode leftPerson) {
+        if (rightPerson != null) {
+            return rightPerson.path("person").path("nachname").asText("") + ", " + 
+                   rightPerson.path("person").path("vorname").asText("");
+        } else if (leftPerson != null) {
+            return leftPerson.path("person").path("nachname").asText("") + ", " + 
+                   leftPerson.path("person").path("vorname").asText("");
+        }
+        return "(keine Person)";
+    }
+    
+    private Map<String, String> createPersonNameMap(JsonNode rightZeitraum, JsonNode leftZeitraum) {
+        Map<String, String> personNameById = new HashMap<>();
+        
+        // Add all persons from right (current) JSON
+        List<PersonRef> personenRight = jsonNavigationService.listRightPersons(rightZeitraum);
+        personenRight.forEach(p -> personNameById.put(p.id(), p.label()));
+        
+        // Add persons from left JSON that are not already in the map
+        if (leftZeitraum != null) {
+            List<PersonRef> personenLeft = jsonNavigationService.listRightPersons(leftZeitraum);
+            personenLeft.stream()
+                .filter(p -> !personNameById.containsKey(p.id()))
+                .forEach(p -> personNameById.put(p.id(), p.label()));
+        }
+        
+        return personNameById;
+    }
+
+    // ===== Helper Classes =====
+    
+    private record PersonComparisonContext(
+        Map<String, Pair<JsonNode, JsonNode>> personenPairs,
+        String personLabel,
+        List<EinkommenZeile> einkommenZeilen,
+        List<FreibetragZeile> freibetragZeilen,
+        List<KitaZeile> kitaZeilen
+    ) {}
+}

+ 0 - 312
src/main/java/com/tsystems/inwo/vgl/web/VergleichController.java

@@ -12,22 +12,6 @@ import java.util.stream.Collectors;
 
 @Controller
 public class VergleichController {
-    private final ObjectMapper om;
-    private final SchemaValidationService validator;
-    private final JsonNavigationService jsonNavigationService;
-    private final DataComparisonService comparisonService;
-    private final VergleichDataExtractor dataExtractor;
-    
-    public VergleichController(ObjectMapper om, SchemaValidationService validator, 
-                              JsonNavigationService jsonNavigationService,
-                              DataComparisonService comparisonService,
-                              VergleichDataExtractor dataExtractor) {
-        this.om = om;
-        this.validator = validator;
-        this.jsonNavigationService = jsonNavigationService;
-        this.comparisonService = comparisonService;
-        this.dataExtractor = dataExtractor;
-    }
 
     @GetMapping("/test")
     public String test(
@@ -43,306 +27,10 @@ public class VergleichController {
         return "test";
     }
 
-    @PostMapping("/vergleich")
-    public String vergleich(
-            @RequestParam String leftJson,
-            @RequestParam String rightJson,
-            @RequestParam(required = false, defaultValue = "true") boolean validate,
-            @RequestParam(required = false, defaultValue = "haushalt-einkommen") String schemaName,
-            @RequestParam(required = false) String leftVorgangId,
-            @RequestParam(required = false) String leftZeitraumId,
-            @RequestParam(required = false) String rightZeitraumId,
-            @RequestParam(required = false) String personId,
-            @RequestParam(required = false) String kunde,
-            @RequestHeader(value = "X-Kunde", required = false) String kundeHeader,
-            Model model) throws Exception {
-
-        String activeKunde = resolveActiveKunde(kunde, kundeHeader);
-
-        JsonNode left = om.readTree(leftJson);
-        JsonNode right = om.readTree(rightJson);
-
-        if (validate) {
-            List<String> err = new ArrayList<>();
-            err.addAll(validator.validate(left, schemaName));
-            err.addAll(validator.validate(right, schemaName));
-            if (!err.isEmpty()) {
-                model.addAttribute("schemaErrors", err);
-                model.addAttribute("leftJson", leftJson);
-                model.addAttribute("rightJson", rightJson);
-                model.addAttribute("validate", validate);
-                model.addAttribute("schemaName", schemaName);
-                return "test";
-            }
-        }
-
-        // --- Rechts (Master) Zeitraum wählen ---
-        List<Z> zeitrechte = jsonNavigationService.extractZeitraeume(right);
-        if (zeitrechte.isEmpty()) throw new IllegalArgumentException("Right JSON hat keine Zeiträume");
-        if (rightZeitraumId == null) rightZeitraumId = zeitrechte.get(0).id;
-        JsonNode rightZeitraum = jsonNavigationService.findZeitraumById(right, rightZeitraumId);
-
-        // --- Find the stammvorgang that contains the selected zeitraum ---
-        JsonNode stammvorgangJsonNode = jsonNavigationService.findStammvorgangByZeitraumId(right, rightZeitraumId);
-        StammvorgangInfo stammvorgangRight = jsonNavigationService.createStammvorgangInfo(stammvorgangJsonNode);
-
-        // --- Extract stammakte info if available ---
-        StammakteInfo stammakteRight = jsonNavigationService.extractStammakteInfo(right);
-
-        // --- Links (Baseline) Kandidaten & Auswahl ---
-        List<Vorgang> leftCand = jsonNavigationService.parseLeftCandidates(left);
-        List<Z> leftZ = List.of();
-        JsonNode leftZeitraum = null;
-        if (!leftCand.isEmpty()) {
-            if (leftVorgangId == null) leftVorgangId = leftCand.get(0).id;
-            final String finalLeftVorgangId = leftVorgangId;
-            leftZ = leftCand.stream().filter(v -> v.id.equals(finalLeftVorgangId)).findFirst().orElse(leftCand.get(0)).zeitraeume;
-            if (leftZeitraumId == null && !leftZ.isEmpty()) leftZeitraumId = leftZ.get(0).id;
-            if (leftZeitraumId != null) leftZeitraum = jsonNavigationService.findZeitraumById(jsonNavigationService.getRootOfVorgang(left, leftVorgangId), leftZeitraumId);
-        }
-
-        // --- Personenliste rechts + Default personId ---
-        List<PersonRef> personenRight = jsonNavigationService.listRightPersons(rightZeitraum);
-        if (personId == null && !personenRight.isEmpty()) personId = personenRight.get(0).id;
-
-        // --- Personen-Tabelle (UNION via person.id) ---
-        PersonComparisonContext context = createPersonComparisonContext(rightZeitraum, leftZeitraum, personId);
-        List<MitgliedRow> mitgliederRows = context.personenPairs.entrySet().stream()
-                .map(e -> dataExtractor.buildMitgliedRow(e.getKey(), e.getValue().right, e.getValue().left))
-                .sorted(Comparator.comparing(MitgliedRow::personId))
-                .toList();
-
-        // --- Model füllen & rendern ---
-        model.addAttribute("leftJson", leftJson);
-        model.addAttribute("rightJson", rightJson);
-        model.addAttribute("validate", validate);
-        model.addAttribute("schemaName", schemaName);
-        model.addAttribute("leftCandidates", leftCand);
-        model.addAttribute("leftZeitraeumeOfSelected", leftZ);
-        model.addAttribute("leftVorgangId", leftVorgangId);
-        model.addAttribute("leftZeitraumId", leftZeitraumId);
-        model.addAttribute("zeitraeumeRight", zeitrechte);
-        model.addAttribute("rightZeitraumId", rightZeitraumId);
-        model.addAttribute("stammvorgangRight", stammvorgangRight);
-        model.addAttribute("stammakteRight", stammakteRight);
-        model.addAttribute("personenRight", personenRight);
-        model.addAttribute("personId", personId);
-        model.addAttribute("mitgliederRows", mitgliederRows);
-        model.addAttribute("personLabel", context.personLabel);
-        model.addAttribute("einkommenZeilen", context.einkommenZeilen);
-        model.addAttribute("freibetragZeilen", context.freibetragZeilen);
-        model.addAttribute("kitaZeilen", context.kitaZeilen);
-        model.addAttribute("activeKunde", activeKunde);
-        return "vergleich";
-    }
-
-    @PostMapping("/api/person-details")
-    @ResponseBody
-    public ResponseEntity<PersonDetailsResponse> getPersonDetails(
-            @RequestParam String leftJson,
-            @RequestParam String rightJson,
-            @RequestParam String leftVorgangId,
-            @RequestParam String leftZeitraumId,
-            @RequestParam String rightZeitraumId,
-            @RequestParam String personId) throws Exception {
-
-        JsonNode left = om.readTree(leftJson);
-        JsonNode right = om.readTree(rightJson);
-
-        JsonNode rightZeitraum = jsonNavigationService.findZeitraumById(right, rightZeitraumId);
-        JsonNode leftZeitraum = null;
-        if (leftZeitraumId != null) {
-            leftZeitraum = jsonNavigationService.findZeitraumById(jsonNavigationService.getRootOfVorgang(left, leftVorgangId), leftZeitraumId);
-        }
-
-        PersonComparisonContext context = createPersonComparisonContext(rightZeitraum, leftZeitraum, personId);
-
-        PersonDetailsResponse response = new PersonDetailsResponse(
-                personId,
-                context.personLabel,
-                context.einkommenZeilen,
-                context.freibetragZeilen,
-                context.kitaZeilen
-        );
-
-        return ResponseEntity.ok(response);
-    }
-
-    @PostMapping("/api/vorgang-change")
-    @ResponseBody
-    public ResponseEntity<VorgangChangeResponse> changeLeftVorgang(
-            @RequestParam String leftJson,
-            @RequestParam String rightJson,
-            @RequestParam String rightZeitraumId,
-            @RequestParam String personId,
-            @RequestParam String newLeftVorgangId) throws Exception {
-
-        JsonNode left = om.readTree(leftJson);
-        JsonNode right = om.readTree(rightJson);
-
-        // --- Links (Baseline) Kandidaten & neue Auswahl ---
-        List<Vorgang> leftCand = jsonNavigationService.parseLeftCandidates(left);
-        
-        final String finalLeftVorgangId = newLeftVorgangId;
-        List<Z> leftZ = leftCand.stream()
-                .filter(v -> v.id.equals(finalLeftVorgangId))
-                .findFirst()
-                .orElse(leftCand.get(0))
-                .zeitraeume;
-        
-        // Ersten Zeitraum des neuen Vorgangs als Default wählen
-        String newLeftZeitraumId = !leftZ.isEmpty() ? leftZ.get(0).id : null;
-        
-        JsonNode rightZeitraum = jsonNavigationService.findZeitraumById(right, rightZeitraumId);
-        JsonNode leftZeitraum = null;
-        if (newLeftZeitraumId != null) {
-            leftZeitraum = jsonNavigationService.findZeitraumById(jsonNavigationService.getRootOfVorgang(left, newLeftVorgangId), newLeftZeitraumId);
-        }
-
-        PersonComparisonContext context = createPersonComparisonContext(rightZeitraum, leftZeitraum, personId);
-        List<MitgliedRow> mitgliederRows = context.personenPairs.entrySet().stream()
-                .map(e -> dataExtractor.buildMitgliedRow(e.getKey(), e.getValue().right, e.getValue().left))
-                .sorted(Comparator.comparing(MitgliedRow::personId))
-                .toList();
-
-        VorgangChangeResponse response = new VorgangChangeResponse(
-                newLeftVorgangId,
-                newLeftZeitraumId,
-                leftZ,
-                mitgliederRows,
-                personId,
-                context.personLabel,
-                context.einkommenZeilen,
-                context.freibetragZeilen,
-                context.kitaZeilen
-        );
-
-        return ResponseEntity.ok(response);
-    }
-
-    @PostMapping("/api/zeitraum-change")
-    @ResponseBody
-    public ResponseEntity<ZeitraumChangeResponse> changeLeftZeitraum(
-            @RequestParam String leftJson,
-            @RequestParam String rightJson,
-            @RequestParam String leftVorgangId,
-            @RequestParam String rightZeitraumId,
-            @RequestParam String personId,
-            @RequestParam String newLeftZeitraumId) throws Exception {
-
-        JsonNode left = om.readTree(leftJson);
-        JsonNode right = om.readTree(rightJson);
-
-        JsonNode rightZeitraum = jsonNavigationService.findZeitraumById(right, rightZeitraumId);
-        JsonNode leftZeitraum = null;
-        if (newLeftZeitraumId != null) {
-            leftZeitraum = jsonNavigationService.findZeitraumById(jsonNavigationService.getRootOfVorgang(left, leftVorgangId), newLeftZeitraumId);
-        }
-
-        PersonComparisonContext context = createPersonComparisonContext(rightZeitraum, leftZeitraum, personId);
-        List<MitgliedRow> mitgliederRows = context.personenPairs.entrySet().stream()
-                .map(e -> dataExtractor.buildMitgliedRow(e.getKey(), e.getValue().right, e.getValue().left))
-                .sorted(Comparator.comparing(MitgliedRow::personId))
-                .toList();
-
-        ZeitraumChangeResponse response = new ZeitraumChangeResponse(
-                newLeftZeitraumId,
-                mitgliederRows,
-                personId,
-                context.personLabel,
-                context.einkommenZeilen,
-                context.freibetragZeilen,
-                context.kitaZeilen
-        );
-
-        return ResponseEntity.ok(response);
-    }
-
     // ===== Private Helper Methods =====
     
     private String resolveActiveKunde(String kunde, String kundeHeader) {
         return kunde != null ? kunde : kundeHeader;
     }
     
-    private PersonComparisonContext createPersonComparisonContext(JsonNode rightZeitraum, JsonNode leftZeitraum, String personId) {
-        // Personen-Tabelle (UNION via person.id)
-        Map<String, Pair<JsonNode, JsonNode>> personenPairs = comparisonService.unionByKey(
-                jsonNavigationService.personsArray(rightZeitraum), 
-                jsonNavigationService.personsArray(leftZeitraum), 
-                n -> n.path("person").path("id").asText()
-        );
-
-        // Details für ausgewählte Person
-        JsonNode rightPerson = personenPairs.getOrDefault(personId, new Pair<>(null, null)).right;
-        JsonNode leftPerson = personenPairs.getOrDefault(personId, new Pair<>(null, null)).left;
-
-        String personLabel = createPersonLabel(rightPerson, leftPerson);
-        Map<String, String> personNameById = createPersonNameMap(rightZeitraum, leftZeitraum);
-
-        // Einkommen / Freibeträge / Kita: jeweils UNION per id
-        List<EinkommenZeile> einkommenZeilen = dataExtractor.buildEinkommenZeilen(rightPerson, leftPerson);
-        List<FreibetragZeile> freibetragZeilen = dataExtractor.buildFreibetragZeilen(rightPerson, leftPerson);
-        List<KitaZeile> kitaZeilen = dataExtractor.buildKitaZeilen(rightPerson, leftPerson, personNameById);
-
-        return new PersonComparisonContext(personenPairs, personLabel, einkommenZeilen, freibetragZeilen, kitaZeilen);
-    }
-    
-    private String createPersonLabel(JsonNode rightPerson, JsonNode leftPerson) {
-        if (rightPerson != null) {
-            return rightPerson.path("person").path("nachname").asText("") + ", " + 
-                   rightPerson.path("person").path("vorname").asText("");
-        } else if (leftPerson != null) {
-            return leftPerson.path("person").path("nachname").asText("") + ", " + 
-                   leftPerson.path("person").path("vorname").asText("");
-        }
-        return "(keine Person)";
-    }
-    
-    private Map<String, String> createPersonNameMap(JsonNode rightZeitraum, JsonNode leftZeitraum) {
-        Map<String, String> personNameById = new HashMap<>();
-        
-        // Add all persons from right (current) JSON
-        List<PersonRef> personenRight = jsonNavigationService.listRightPersons(rightZeitraum);
-        personenRight.forEach(p -> personNameById.put(p.id, p.label));
-        
-        // Add persons from left JSON that are not already in the map
-        if (leftZeitraum != null) {
-            List<PersonRef> personenLeft = jsonNavigationService.listRightPersons(leftZeitraum);
-            personenLeft.stream()
-                .filter(p -> !personNameById.containsKey(p.id))
-                .forEach(p -> personNameById.put(p.id, p.label));
-        }
-        
-        return personNameById;
-    }
-
-    // ===== Helper Classes =====
-    
-    private record PersonComparisonContext(
-        Map<String, Pair<JsonNode, JsonNode>> personenPairs,
-        String personLabel,
-        List<EinkommenZeile> einkommenZeilen,
-        List<FreibetragZeile> freibetragZeilen,
-        List<KitaZeile> kitaZeilen
-    ) {}
-
-    // ===== Public Records =====
-    
-    public record Z(String id, String beginn, String ende) {}
-    public record Vorgang(String id, String vorgangstyp, List<Z> zeitraeume) {}
-    public record PersonRef(String id, String label) {}
-    public record StammvorgangInfo(String id, String vorgangstyp, String vorgangsstatus) {}
-    public record StammakteInfo(String aktenzeichen, String antragstellername, String antragstellerGeburtsdatum, String anschrift) {}
-    public record PersonDetailsResponse(String personId, String personLabel, List<EinkommenZeile> einkommenZeilen, List<FreibetragZeile> freibetragZeilen, List<KitaZeile> kitaZeilen) {}
-    public record VorgangChangeResponse(String leftVorgangId, String leftZeitraumId, List<Z> leftZeitraeume, List<MitgliedRow> mitgliederRows, String personId, String personLabel, List<EinkommenZeile> einkommenZeilen, List<FreibetragZeile> freibetragZeilen, List<KitaZeile> kitaZeilen) {}
-    public record ZeitraumChangeResponse(String leftZeitraumId, List<MitgliedRow> mitgliederRows, String personId, String personLabel, List<EinkommenZeile> einkommenZeilen, List<FreibetragZeile> freibetragZeilen, List<KitaZeile> kitaZeilen) {}
-    
-    public static final class Pair<R, L> {
-        public final R right;
-        public final L left;
-        public Pair(R r, L l) {
-            right = r;
-            left = l;
-        }
-    }
 }

+ 427 - 0
src/main/resources/schemas/miete.schema.json

@@ -0,0 +1,427 @@
+{
+  "$id": "https://example.com/miete.schema.json",
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "title": "Vergleichsansicht Seite Miete",
+  "description": "Seite mit Stammakte → Vorgänge → Zeiträume → Miete je Zeitraum inkl. Untermieter und Mitbewohner.",
+  "type": "object",
+  "oneOf": [
+    {
+      "additionalProperties": false,
+      "properties": {
+        "stammakte": {
+          "$ref": "#/$defs/Stammakte"
+        }
+      },
+      "required": ["stammakte"]
+    },
+    {
+      "additionalProperties": false,
+      "properties": {
+        "vorgaenge": {
+          "type": "array",
+          "items": {
+            "$ref": "#/$defs/Vorgang"
+          }
+        }
+      },
+      "required": ["vorgaenge"]
+    },
+    {
+      "additionalProperties": false,
+      "properties": {
+        "stammvorgang": {
+          "$ref": "#/$defs/Stammvorgang"
+        },
+        "zeitraeume": {
+          "type": "array",
+          "items": {
+            "$ref": "#/$defs/Zeitraum"
+          }
+        }
+      },
+      "required": ["stammvorgang", "zeitraeume"]
+    }
+  ],
+  "$defs": {
+    "Stammakte": {
+      "type": "object",
+      "additionalProperties": false,
+      "properties": {
+        "aktenzeichen": {
+          "type": "string"
+        },
+        "antragstellername": {
+          "type": "string"
+        },
+        "antragstellerGeburtsdatum": {
+          "type": "string",
+          "format": "date"
+        },
+        "anschrift": {
+          "$ref": "#/$defs/Adresse"
+        },
+        "vorgaenge": {
+          "type": "array",
+          "items": {
+            "$ref": "#/$defs/Vorgang"
+          }
+        }
+      },
+      "required": ["aktenzeichen", "antragstellername", "vorgaenge"]
+    },
+    "Vorgang": {
+      "type": "object",
+      "additionalProperties": false,
+      "properties": {
+        "stammvorgang": {
+          "$ref": "#/$defs/Stammvorgang"
+        },
+        "zeitraeume": {
+          "type": "array",
+          "items": {
+            "$ref": "#/$defs/Zeitraum"
+          }
+        }
+      },
+      "required": ["stammvorgang", "zeitraeume"]
+    },
+    "Stammvorgang": {
+      "type": "object",
+      "additionalProperties": false,
+      "properties": {
+        "id": {
+          "type": "string"
+        },
+        "vorgangstyp": {
+          "type": "string",
+          "description": "Typ des Vorgangs (Ersatz für bemerkung)"
+        },
+        "vorgangsstatus": {
+          "type": "string",
+          "description": "Status des Vorgangs"
+        }
+      },
+      "required": ["id"]
+    },
+    "Adresse": {
+      "type": "object",
+      "additionalProperties": false,
+      "properties": {
+        "strasse": {
+          "type": "string"
+        },
+        "hausnummer": {
+          "type": "string"
+        },
+        "zusatz": {
+          "type": "string"
+        },
+        "plz": {
+          "type": "string",
+          "pattern": "^[0-9]{5}$"
+        },
+        "ort": {
+          "type": "string"
+        }
+      }
+    },
+    "Miete": {
+      "type": "object",
+      "additionalProperties": false,
+      "properties": {
+        "id": {
+          "type": "string",
+          "description": "Miete ID"
+        },
+        "miete": {
+          "type": "number",
+          "minimum": 0,
+          "description": "Miete Betrag"
+        },
+        "mieteAlt": {
+          "type": "number",
+          "minimum": 0,
+          "description": "Alte Miete"
+        },
+        "sonstZuschlag": {
+          "type": "number",
+          "minimum": 0,
+          "description": "Sonstiger Zuschlag"
+        },
+        "beitragDritter": {
+          "type": "number",
+          "minimum": 0,
+          "description": "Beitrag Dritter"
+        },
+        "heizung": {
+          "type": "number",
+          "minimum": 0,
+          "description": "Heizung Betrag"
+        },
+        "heizungP": {
+          "type": "string",
+          "description": "Heizung Beschreibung"
+        },
+        "warmwasser": {
+          "type": "number",
+          "minimum": 0,
+          "description": "Warmwasser Betrag"
+        },
+        "warmwasserP": {
+          "type": "string",
+          "description": "Warmwasser Beschreibung"
+        },
+        "moeblierung": {
+          "type": "number",
+          "minimum": 0,
+          "description": "Möblierung Betrag"
+        },
+        "moeblierungP": {
+          "type": "string",
+          "description": "Möblierung Beschreibung"
+        },
+        "umietZuschlag": {
+          "type": "number",
+          "minimum": 0,
+          "description": "Umiet Zuschlag"
+        },
+        "gewerbeZuschlag": {
+          "type": "number",
+          "minimum": 0,
+          "description": "Gewerbe Zuschlag"
+        },
+        "gewerbeZuschlagP": {
+          "type": "string",
+          "description": "Gewerbe Zuschlag Beschreibung"
+        },
+        "gewerbeFlaeche": {
+          "type": "number",
+          "minimum": 0,
+          "description": "Gewerbe Fläche"
+        },
+        "jhrlFernwarmw": {
+          "type": "number",
+          "minimum": 0,
+          "description": "Jährlich Fernwarmwasser"
+        },
+        "jhrlFernwaerme": {
+          "type": "number",
+          "minimum": 0,
+          "description": "Jährlich Fernwärme"
+        },
+        "antMietWw": {
+          "type": "number",
+          "minimum": 0,
+          "description": "Anteil Miete Warmwasser"
+        },
+        "antBrutWw": {
+          "type": "number",
+          "minimum": 0,
+          "description": "Anteil Brutto Warmwasser"
+        },
+        "jhrlFernWUndW": {
+          "type": "number",
+          "minimum": 0,
+          "description": "Jährlich Fernwärme und Warmwasser"
+        },
+        "lstgAufenthaltPerson": {
+          "type": "number",
+          "minimum": 0,
+          "description": "Leistung Aufenthalt Person"
+        },
+        "sonstWogFaehigBetrag": {
+          "type": "number",
+          "minimum": 0,
+          "description": "Sonstiger wohnungsfähiger Betrag"
+        },
+        "sonstWogFaehigText": {
+          "type": "string",
+          "description": "Sonstiger wohnungsfähiger Text"
+        },
+        "sonstZuschlagBeschr": {
+          "type": "string",
+          "description": "Sonstiger Zuschlag Beschreibung"
+        },
+        "energie": {
+          "type": "number",
+          "minimum": 0,
+          "description": "Energie Betrag"
+        },
+        "energieP": {
+          "type": "string",
+          "description": "Energie Beschreibung"
+        },
+        "stellplatz": {
+          "type": "number",
+          "minimum": 0,
+          "description": "Stellplatz Betrag"
+        },
+        "stellplatzP": {
+          "type": "string",
+          "description": "Stellplatz Beschreibung"
+        },
+        "untermieter": {
+          "type": "array",
+          "items": {
+            "$ref": "#/$defs/Untermieter"
+          },
+          "description": "Liste der Untermieter"
+        },
+        "mitbewohner": {
+          "type": "array",
+          "items": {
+            "$ref": "#/$defs/Mitbewohner"
+          },
+          "description": "Liste der Mitbewohner"
+        }
+      },
+      "required": ["id"]
+    },
+    "Untermieter": {
+      "type": "object",
+      "additionalProperties": false,
+      "properties": {
+        "miId": {
+          "type": "string",
+          "description": "Miete ID Referenz"
+        },
+        "lfdnr": {
+          "type": "integer",
+          "minimum": 1,
+          "maximum": 99,
+          "description": "Laufende Nummer"
+        },
+        "name": {
+          "type": "string",
+          "description": "Nachname des Untermieters"
+        },
+        "vorname": {
+          "type": "string",
+          "description": "Vorname des Untermieters"
+        },
+        "flaeche": {
+          "type": "number",
+          "minimum": 0,
+          "description": "Fläche"
+        },
+        "miete": {
+          "type": "number",
+          "minimum": 0,
+          "description": "Miete des Untermieters"
+        },
+        "heizung": {
+          "type": "number",
+          "minimum": 0,
+          "description": "Heizung Betrag"
+        },
+        "heizungP": {
+          "type": "string",
+          "description": "Heizung Beschreibung"
+        },
+        "warmwasser": {
+          "type": "number",
+          "minimum": 0,
+          "description": "Warmwasser Betrag"
+        },
+        "warmwasserP": {
+          "type": "string",
+          "description": "Warmwasser Beschreibung"
+        },
+        "moeblierung": {
+          "type": "number",
+          "minimum": 0,
+          "description": "Möblierung Betrag"
+        },
+        "moeblierungP": {
+          "type": "string",
+          "description": "Möblierung Beschreibung"
+        },
+        "sonst": {
+          "type": "number",
+          "minimum": 0,
+          "description": "Sonstiges"
+        }
+      },
+      "required": ["miId", "lfdnr", "name", "vorname"]
+    },
+    "Mitbewohner": {
+      "type": "object",
+      "additionalProperties": false,
+      "properties": {
+        "miId": {
+          "type": "string",
+          "description": "Miete ID Referenz"
+        },
+        "lfdnr": {
+          "type": "integer",
+          "minimum": 1,
+          "maximum": 99,
+          "description": "Laufende Nummer"
+        },
+        "name": {
+          "type": "string",
+          "description": "Nachname des Mitbewohners"
+        },
+        "vorname": {
+          "type": "string",
+          "description": "Vorname des Mitbewohners"
+        },
+        "entgelt": {
+          "type": "number",
+          "minimum": 0,
+          "description": "Entgelt"
+        },
+        "heizung": {
+          "type": "number",
+          "minimum": 0,
+          "description": "Heizung Betrag"
+        },
+        "heizungP": {
+          "type": "string",
+          "description": "Heizung Beschreibung"
+        },
+        "warmwasser": {
+          "type": "number",
+          "minimum": 0,
+          "description": "Warmwasser Betrag"
+        },
+        "warmwasserP": {
+          "type": "string",
+          "description": "Warmwasser Beschreibung"
+        },
+        "sonst": {
+          "type": "number",
+          "minimum": 0,
+          "description": "Sonstiges"
+        }
+      },
+      "required": ["miId", "lfdnr", "name", "vorname"]
+    },
+    "Zeitraum": {
+      "type": "object",
+      "additionalProperties": false,
+      "properties": {
+        "id": {
+          "type": "string"
+        },
+        "beginn": {
+          "type": "string",
+          "format": "date"
+        },
+        "ende": {
+          "type": "string",
+          "format": "date"
+        },
+        "miete": {
+          "$ref": "#/$defs/Miete",
+          "description": "Genau ein Miete-Datensatz pro Zeitraum"
+        }
+      },
+      "required": [
+        "id",
+        "beginn",
+        "miete"
+      ]
+    }
+  }
+}

+ 278 - 0
src/main/resources/templates/hhm-einkommen.html

@@ -0,0 +1,278 @@
+<!DOCTYPE html>
+<html lang="de" xmlns:th="http://www.thymeleaf.org">
+<head>
+    <meta charset="UTF-8"/>
+    <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
+    <title>DIWO Vergleich – Haushaltsmitglieder/Einkommen</title>
+    <link rel="stylesheet" href="/styles.css"/>
+    <!-- JavaScript Module -->
+    <script src="/js/cookie-utils.js"></script>
+    <script src="/js/ajax-handlers.js"></script>
+    <script src="/js/ui-updater.js"></script>
+    <script src="/js/interactions.js"></script>
+    <script src="/js/color-scheme.js"></script>
+    <script src="/js/app.js"></script>
+</head>
+<body>
+
+  <!-- Header-Fragment einbinden -->
+  <div th:replace="~{fragments/header :: header}"></div>
+
+  <!-- Vergleichslegende-Fragment einbinden -->
+  <div th:replace="~{fragments/legend :: legend}"></div>
+
+  <!-- Haushaltsmitglieder - erweiterte Tabelle mit aufklappbaren Zeilen -->
+  <main role="main">
+    <div class="section-container">
+      <div class="section-header" onclick="toggleSection('haushaltsmitglieder')" role="button" tabindex="0" 
+           onkeypress="handleKeyPress(event, 'haushaltsmitglieder')" aria-expanded="true" aria-controls="haushaltsmitglieder-content">
+        <span class="toggle-icon" id="haushaltsmitglieder-icon" aria-hidden="true">▼</span>
+        <h3>Haushaltsmitglieder</h3>
+      </div>
+      <div class="section-content" id="haushaltsmitglieder-content">
+        <table role="table" aria-labelledby="haushaltsmitglieder-caption" class="expandable-table" id="haushaltsmitglieder-table">
+          <thead>
+            <tr>
+              <th scope="col" class="expand-col"></th>
+              <th scope="col">Änderung</th>
+              <th scope="col">Name</th>
+              <th scope="col">Blockname</th>
+              <th scope="col">Geburtsdatum</th>
+              <th scope="col">Sterbedatum</th>
+              <th scope="col">Familienstand</th>
+              <th scope="col">Geschlecht</th>
+              <th scope="col">Anrede</th>
+              <th scope="col">Verwandtschaft</th>
+              <th scope="col">Ausschlussgrund</th>
+              <th scope="col">Soz. Stellung</th>
+              <th scope="col">Antragsteller</th>
+              <th scope="col">Zahlungsempfänger</th>
+              <th scope="col">Ende Aufenthaltstitel</th>
+            </tr>
+          </thead>
+          <tbody>
+            <th:block th:each="r : ${mitgliederRows}">
+              <!-- Hauptzeile -->
+              <tr th:class="'person-row ' + ${r.rowClass} + ((${r.personId} == ${personId}) ? ' person-selected' : '')"
+                  th:data-person-id="${r.personId}"
+                  role="button"
+                  tabindex="0"
+                  th:aria-label="'Person auswählen: ' + ${r.nameHtml}"
+                  onkeypress="handleRowKeyPress(event, this)">
+                <td class="expand-col">
+                  <button type="button" 
+                          class="expand-btn" 
+                          th:data-person-id="${r.personId}"
+                          onclick="togglePersonDetailsFromButton(this)"
+                          onkeypress="handleExpandKeyPressFromButton(event, this)"
+                          aria-expanded="false"
+                          th:aria-controls="'person-details-' + ${r.personId}"
+                          th:aria-label="'Details für ' + ${r.nameHtml} + ' anzeigen'">
+                    <span aria-hidden="true">▶</span>
+                  </button>
+                </td>
+                <td class="sym" th:classappend="' ' + ${r.symbolClass}" th:text="${r.symbol}" 
+                    th:aria-label="${r.symbol == '+' ? 'Hinzugefügt' : (r.symbol == '-' ? 'Gelöscht' : (r.symbol == '!' ? 'Geändert' : 'Unverändert'))}"></td>
+                <td th:utext="${r.nameHtml}"></td>
+                <td th:utext="${r.blocknameHtml}"></td>
+                <td th:utext="${r.geburtsdatumHtml}"></td>
+                <td th:utext="${r.sterbedatumHtml}"></td>
+                <td th:utext="${r.familienstandHtml}"></td>
+                <td th:utext="${r.geschlechtHtml}"></td>
+                <td th:utext="${r.anredeHtml}"></td>
+                <td th:utext="${r.verwandtschaftHtml}"></td>
+                <td th:utext="${r.ausschlussgrundHtml}"></td>
+                <td th:utext="${r.sozialstellungHtml}"></td>
+                <td th:utext="${r.antragstellerHtml}"></td>
+                <td th:utext="${r.zahlungsempfaengerHtml}"></td>
+                <td th:utext="${r.aufenthaltstitelEndeHtml}"></td>
+              </tr>
+              
+              <!-- Aufklappbare Detailzeile -->
+              <tr th:id="'person-details-' + ${r.personId}" 
+                  class="person-details-row hidden"
+                  th:aria-labelledby="'person-details-label-' + ${r.personId}">
+                <td colspan="15" class="details-cell">
+                  <div class="details-container">
+                    <table class="details-table">
+                      <tbody>
+                        <tr>
+                          <td class="detail-label">Titel:</td>
+                          <td th:utext="${r.titelHtml}"></td>
+                          <td class="detail-label">Geburtsort:</td>
+                          <td th:utext="${r.geburtsortHtml}"></td>
+                          <td class="detail-label">Telefon:</td>
+                          <td th:utext="${r.telefonHtml}"></td>
+                          <td class="detail-label">Aktenzeichen Soz.:</td>
+                          <td th:utext="${r.aktenzeichenSozialHtml}"></td>
+                        </tr>
+                        <tr>
+                          <td class="detail-label">Geburtsname:</td>
+                          <td th:utext="${r.geburtsnameHtml}"></td>
+                          <td class="detail-label">Alter Name:</td>
+                          <td th:utext="${r.alterNameHtml}"></td>
+                          <td class="detail-label">E-Mail:</td>
+                          <td th:utext="${r.emailHtml}"></td>
+                          <td class="detail-label">Postabrutionsnummer:</td>
+                          <td th:utext="${r.postabrutionsnummerHtml}"></td>
+                        </tr>
+                      </tbody>
+                    </table>
+                  </div>
+                </td>
+              </tr>
+            </th:block>
+          </tbody>
+        </table>
+      </div>
+    </div>
+
+    <!-- Hidden data for AJAX requests -->
+    <div id="ajax-data" style="display: none;" aria-hidden="true">
+      <input type="hidden" id="leftJson" th:value="${leftJson}"/>
+      <input type="hidden" id="rightJson" th:value="${rightJson}"/>
+      <input type="hidden" id="leftVorgangId" th:value="${leftVorgangId}"/>
+      <input type="hidden" id="leftZeitraumId" th:value="${leftZeitraumId}"/>
+      <input type="hidden" id="rightZeitraumId" th:value="${rightZeitraumId}"/>
+      <input type="hidden" id="currentPersonId" th:value="${personId}"/>
+    </div>
+
+    <h2 id="person-details-title">Details zu <span th:if="${personLabel != null and !#strings.isEmpty(personLabel) and personLabel != '(keine Person)'}"><span th:text="${personLabel}"> - </span></span></h2>
+    
+    <!-- Einkommen -->
+    <div class="section-container">
+      <div class="section-header" onclick="toggleSection('einkommen')" role="button" tabindex="0" 
+           onkeypress="handleKeyPress(event, 'einkommen')" aria-expanded="true" aria-controls="einkommen-content">
+        <span class="toggle-icon" id="einkommen-icon" aria-hidden="true">▼</span>
+        <h3>Einkommen</h3>
+      </div>
+      <div class="section-content" id="einkommen-content">
+        <table role="table" aria-labelledby="einkommen-caption" id="einkommen-table">
+          <thead>
+            <tr>
+              <th scope="col">Änderung</th>
+              <th scope="col">Art</th>
+              <th scope="col">Ergänzung</th>
+              <th scope="col">Faktor</th>
+              <th scope="col">Betrag</th>
+              <th scope="col">Zuschlag</th>
+              <th scope="col">Abschlag</th>
+              <th scope="col">Werbungskosten</th>
+            </tr>
+          </thead>
+          <tbody>
+            <tr th:each="z : ${einkommenZeilen}" th:class="${z.rowClass}">
+              <td class="sym" th:classappend="' ' + ${z.symbolClass}" th:text="${z.symbol}"
+                  th:aria-label="${z.symbol == '+' ? 'Hinzugefügt' : (z.symbol == '-' ? 'Gelöscht' : (z.symbol == '!' ? 'Geändert' : 'Unverändert'))}"></td>
+              <td th:utext="${z.artHtml}"></td>
+              <td th:utext="${z.artZusatzHtml}"></td>
+              <td th:utext="${z.faktorHtml}"></td>
+              <td th:utext="${z.betragHtml}"></td>
+              <td th:utext="${z.zuschlagHtml}"></td>
+              <td th:utext="${z.abschlagHtml}"></td>
+              <td th:utext="${z.werbungskostenHtml}"></td>
+            </tr>
+          </tbody>
+        </table>
+      </div>
+    </div>
+
+    <div class="freibetrag-container">
+      <div class="freibetrag-section">
+        <div class="section-container">
+          <div class="section-header" onclick="toggleSection('pauschale')" role="button" tabindex="0" 
+               onkeypress="handleKeyPress(event, 'pauschale')" aria-expanded="true" aria-controls="pauschale-content">
+            <span class="toggle-icon" id="pauschale-icon" aria-hidden="true">▼</span>
+            <h3>Pauschale Abzüge</h3>
+          </div>
+          <div class="section-content" id="pauschale-content">
+            <table role="table" aria-labelledby="pauschale-caption" id="pauschale-table">
+              <thead>
+                <tr>
+                  <th scope="col">Änderung</th>
+                  <th scope="col">Art</th>
+                  <th scope="col">Faktor</th>
+                  <th scope="col">Betrag</th>
+                </tr>
+              </thead>
+              <tbody>
+                <tr th:each="z : ${freibetragZeilen}" th:if="${z.typ == 'P'}" th:class="${z.rowClass}">
+                  <td class="sym" th:classappend="' ' + ${z.symbolClass}" th:text="${z.symbol}"
+                      th:aria-label="${z.symbol == '+' ? 'Hinzugefügt' : (z.symbol == '-' ? 'Gelöscht' : (z.symbol == '!' ? 'Geändert' : 'Unverändert'))}"></td>
+                  <td th:utext="${z.artHtml}"></td>
+                  <td th:utext="${z.faktorHtml}"></td>
+                  <td th:utext="${z.betragHtml}"></td>
+                </tr>
+              </tbody>
+            </table>
+          </div>
+        </div>
+      </div>
+      
+      <div class="freibetrag-section">
+        <div class="section-container">
+          <div class="section-header" onclick="toggleSection('freibetraege')" role="button" tabindex="0" 
+               onkeypress="handleKeyPress(event, 'freibetraege')" aria-expanded="true" aria-controls="freibetraege-content">
+            <span class="toggle-icon" id="freibetraege-icon" aria-hidden="true">▼</span>
+            <h3>Freibeträge</h3>
+          </div>
+          <div class="section-content" id="freibetraege-content">
+            <table role="table" aria-labelledby="freibetraege-caption" id="freibetraege-table">
+              <thead>
+                <tr>
+                  <th scope="col">Änderung</th>
+                  <th scope="col">Art</th>
+                  <th scope="col">Faktor</th>
+                  <th scope="col">Betrag</th>
+                  <th scope="col">Frist</th>
+                </tr>
+              </thead>
+              <tbody>
+                <tr th:each="z : ${freibetragZeilen}" th:if="${z.typ == 'F'}" th:class="${z.rowClass}">
+                  <td class="sym" th:classappend="' ' + ${z.symbolClass}" th:text="${z.symbol}"
+                      th:aria-label="${z.symbol == '+' ? 'Hinzugefügt' : (z.symbol == '-' ? 'Gelöscht' : (z.symbol == '!' ? 'Geändert' : 'Unverändert'))}"></td>
+                  <td th:utext="${z.artHtml}"></td>
+                  <td th:utext="${z.faktorHtml}"></td>
+                  <td th:utext="${z.betragHtml}"></td>
+                  <td th:utext="${z.fristHtml}"></td>
+                </tr>
+              </tbody>
+            </table>
+          </div>
+        </div>
+      </div>
+    </div>
+
+    <!-- Kinderbetreuungskosten -->
+    <div class="kinderbetreuung-container">
+      <div class="section-container">
+        <div class="section-header" onclick="toggleSection('kinderbetreuung')" role="button" tabindex="0" 
+             onkeypress="handleKeyPress(event, 'kinderbetreuung')" aria-expanded="true" aria-controls="kinderbetreuung-content">
+          <span class="toggle-icon" id="kinderbetreuung-icon" aria-hidden="true">▼</span>
+          <h3>Kinderbetreuungskosten</h3>
+        </div>
+        <div class="section-content" id="kinderbetreuung-content">
+          <table role="table" aria-labelledby="kinderbetreuung-caption" id="kinderbetreuung-table">
+            <thead>
+              <tr>
+                <th scope="col">Änderung</th>
+                <th scope="col">Kind</th>
+                <th scope="col">Betrag</th>
+              </tr>
+            </thead>
+            <tbody>
+              <tr th:each="z : ${kitaZeilen}" th:class="${z.rowClass}">
+                <td class="sym" th:classappend="' ' + ${z.symbolClass}" th:text="${z.symbol}"
+                    th:aria-label="${z.symbol == '+' ? 'Hinzugefügt' : (z.symbol == '-' ? 'Gelöscht' : (z.symbol == '!' ? 'Geändert' : 'Unverändert'))}"></td>
+                <td th:utext="${z.kindNameHtml}"></td>
+                <td th:utext="${z.betragHtml}"></td>
+              </tr>
+            </tbody>
+          </table>
+        </div>
+      </div>
+    </div>
+  </main>
+
+</body>
+</html>

+ 1 - 1
src/main/resources/templates/test.html

@@ -14,7 +14,7 @@
     <ul><li th:each="e : ${schemaErrors}" th:text="${e}"></li></ul>
   </div>
 
-  <form method="post" action="/vergleich" class="grid-2">
+  <form method="post" action="/hhm-einkommen" class="grid-2">
     <div><label>Left JSON (Basis/Alt)</label><textarea id="leftJson" name="leftJson" th:text="${leftJson}" rows="20"></textarea></div>
     <div><label>Right JSON (Neu/Wahrheit)</label><textarea id="rightJson" name="rightJson" th:text="${rightJson}" rows="20"></textarea></div>
     <div class="full row">