package de.votian.web.view;

import com.vaadin.flow.component.Component;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.button.ButtonVariant;
import com.vaadin.flow.component.checkbox.Checkbox;
import com.vaadin.flow.component.checkbox.CheckboxGroup;
import com.vaadin.flow.component.combobox.ComboBox;
import com.vaadin.flow.component.datetimepicker.DateTimePicker;
import com.vaadin.flow.component.dialog.Dialog;
import com.vaadin.flow.component.formlayout.FormLayout;
import com.vaadin.flow.component.grid.Grid;
import com.vaadin.flow.component.html.Anchor;
import com.vaadin.flow.component.html.Div;
import com.vaadin.flow.component.html.H3;
import com.vaadin.flow.component.html.H4;
import com.vaadin.flow.component.html.Image;
import com.vaadin.flow.component.html.Paragraph;
import com.vaadin.flow.component.html.Span;
import com.vaadin.flow.component.icon.VaadinIcon;
import com.vaadin.flow.component.notification.Notification;
import com.vaadin.flow.component.notification.NotificationVariant;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.component.tabs.Tab;
import com.vaadin.flow.component.tabs.Tabs;
import com.vaadin.flow.component.textfield.TextArea;
import com.vaadin.flow.component.textfield.TextField;
import com.vaadin.flow.component.upload.Upload;
import com.vaadin.flow.component.upload.receivers.MemoryBuffer;
import com.vaadin.flow.router.BeforeEvent;
import com.vaadin.flow.router.HasUrlParameter;
import com.vaadin.flow.router.OptionalParameter;
import com.vaadin.flow.router.PageTitle;
import com.vaadin.flow.router.Route;
import com.vaadin.flow.server.StreamResource;
import de.votian.web.model.SessionData;
import de.votian.web.service.RestClientService;
import org.springframework.core.ParameterizedTypeReference;

import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.math.BigDecimal;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;

@Route(value = "jobs/:id?", layout = MainLayout.class)
@PageTitle("Votian - Auftragsdetail")
public class JobDetailView extends VerticalLayout implements HasUrlParameter<String> {

    private static final int JB_MARKUP_MAN = 8;
    private static final int JB_CR_SUBTOTAL = 10;
    private static final int JB_SELF_SERVICE_DISCOUNT = 13;

    private static final List<JobStatusOption> STATUS_OPTIONS = List.of(
            new JobStatusOption(0, "Blockiert"),
            new JobStatusOption(1, "Zugewiesen"),
            new JobStatusOption(2, "In Zustellung"),
            new JobStatusOption(8, "Zugestellt"),
            new JobStatusOption(9, "Abgeschlossen"),
            new JobStatusOption(10, "Storniert")
    );

    private final RestClientService restClient;
    private final SessionData sessionData;

    private Long jobId;
    private boolean isNew = true;

    private final ComboBox<JobStatusOption> statusField = new ComboBox<>("Status");
    private final DateTimePicker orderTimeField = new DateTimePicker("Auftragszeit");
    private final DateTimePicker finishTimeField = new DateTimePicker("Abschlusszeit");
    private final TextField tourNameField = new TextField("Tourname");
    private final TextField courierIdField = new TextField("Kurier-ID");
    private final TextField courierSidField = new TextField("Kurier SID");
    private final TextField vehicleTypeIdField = new TextField("Fahrzeugtyp-ID");
    private final TextField payerCostCenterField = new TextField("Rechnungs-Kostenstelle");
    private final TextField relatedCostCenterField = new TextField("Bezugs-Kostenstelle");
    private final TextField totalPriceField = new TextField("Gesamtpreis");
    private final TextField subtotalPriceField = new TextField("Zwischensumme");
    private final TextField courierPriceField = new TextField("Kurierpreis");
    private final TextField courierSubtotalField = new TextField("Kurier-Zwischensumme");
    private final TextField fixPriceField = new TextField("Fixpreis");
    private final TextField servicePriceField = new TextField("Servicepreis");
    private final TextField markupField = new TextField("Markup");
    private final TextField selfServiceDiscountField = new TextField("Selbsterfasser-Rabatt %");
    private final Checkbox manualLonghaulField = new Checkbox("Manuelle FT");
    private final Span longhaulStateInfo = new Span();
    private final TextArea invoiceTextField = new TextArea("Rechnungstext");
    private final CheckboxGroup<JobServiceOption> servicesField = new CheckboxGroup<>("Leistungen");
    private final List<JobServiceOption> jobServiceOptions = new ArrayList<>();

    private final TourEditor pickupEditor = new TourEditor("Abholung", 1);
    private final TourEditor deliveryEditor = new TourEditor("Zustellung", 2);

    private final Grid<ArticleRow> articleGrid = new Grid<>(ArticleRow.class, false);
    private final List<ArticleRow> articleRows = new ArrayList<>();
    private final Button addArticleButton = new Button("Artikel anlegen", VaadinIcon.PLUS.create());
    private final Button editArticleButton = new Button("Artikel bearbeiten", VaadinIcon.EDIT.create());
    private final Button deleteArticleButton = new Button("Artikel loeschen", VaadinIcon.TRASH.create());

    private final Grid<JobPriceRow> jobPriceGrid = new Grid<>(JobPriceRow.class, false);
    private final List<JobPriceRow> jobPriceRows = new ArrayList<>();
    private final Grid<PersistedTourServiceRow> persistedTourServiceGrid = new Grid<>(PersistedTourServiceRow.class, false);
    private final List<PersistedTourServiceRow> persistedTourServiceRows = new ArrayList<>();
    private final List<JobPriceTypeOption> jobPriceTypeOptions = new ArrayList<>();
    private final Map<Integer, JobPriceTypeOption> jobPriceTypeBySort = new LinkedHashMap<>();
    private final Button addJobPriceButton = new Button("Posten anlegen", VaadinIcon.PLUS.create());
    private final Button editJobPriceButton = new Button("Posten bearbeiten", VaadinIcon.EDIT.create());
    private final Button deleteJobPriceButton = new Button("Posten loeschen", VaadinIcon.TRASH.create());
    private final Button recalculateLegacyPricingButton = new Button("Legacy-Funktionen anwenden", VaadinIcon.CALC.create());
    private final Span jobPriceTotalLabel = new Span();
    private final Span persistedTourServiceTotalLabel = new Span();

    private final MemoryBuffer uploadBuffer = new MemoryBuffer();
    private final Upload documentUpload = new Upload(uploadBuffer);
    private final TextField attachmentRemarkField = new TextField("Bemerkung zum Dokument");
    private final Grid<AttachmentRow> attachmentGrid = new Grid<>(AttachmentRow.class, false);
    private final Div photoGallery = new Div();
    private final List<AttachmentRow> attachmentRows = new ArrayList<>();
    private final List<PhotoRow> photoRows = new ArrayList<>();
    private final TextArea acceptanceProtocolDetailTextField = new TextArea("Protokolltext");
    private final Grid<AcceptanceProtocolQuestionRow> acceptanceProtocolGrid = new Grid<>(AcceptanceProtocolQuestionRow.class, false);
    private final Div acceptanceProtocolOverview = new Div();
    private final Div acceptanceProtocolSignature = new Div();
    private final Anchor acceptanceProtocolPdfLink = new Anchor();
    private final Anchor mediaAcceptanceProtocolPdfLink = new Anchor();
    private BigDecimal loadedCourierPrice;
    private Integer loadedLonghaulState;

    public JobDetailView(RestClientService restClient, SessionData sessionData) {
        this.restClient = restClient;
        this.sessionData = sessionData;

        setPadding(true);
        setSpacing(true);
        setWidthFull();

        configureFields();
        configureArticleGrid();
        configureJobPriceGrid();
        configurePersistedTourServiceGrid();
        configureAttachmentGrid();
        configureAcceptanceProtocolGrid();
        configureUpload();
    }

    @Override
    public void setParameter(BeforeEvent event, @OptionalParameter String parameter) {
        removeAll();
        resetState();
        if (parameter != null && !parameter.isBlank() && !"new".equals(parameter)) {
            try {
                jobId = Long.parseLong(parameter);
                isNew = false;
            } catch (NumberFormatException e) {
                showError("Ungueltige Auftrags-ID.");
            }
        }

        buildForm();
        updateTitle();

        if (!isNew && jobId != null) {
            loadJob();
        }
    }

    private H3 title;

    private void configureFields() {
        statusField.setItems(STATUS_OPTIONS);
        statusField.setItemLabelGenerator(JobStatusOption::label);
        statusField.setValue(findStatus(0));

        orderTimeField.setStep(java.time.Duration.ofMinutes(1));
        finishTimeField.setStep(java.time.Duration.ofMinutes(1));
        orderTimeField.setValue(LocalDateTime.now());

        invoiceTextField.setWidthFull();
        invoiceTextField.setMinHeight("220px");
        invoiceTextField.setMaxLength(4000);

        subtotalPriceField.setWidthFull();
        servicePriceField.setWidthFull();
        courierSubtotalField.setWidthFull();
        selfServiceDiscountField.setWidthFull();
        servicesField.setWidthFull();
        servicesField.setItemLabelGenerator(JobServiceOption::label);
        servicesField.setValue(defaultJobServices());
        manualLonghaulField.setEnabled(sessionData.canManageLonghaul());
        manualLonghaulField.addValueChangeListener(event -> updateLonghaulStateInfo());
        longhaulStateInfo.getStyle()
                .set("color", "var(--lumo-secondary-text-color)")
                .set("font-size", "var(--lumo-font-size-s)");
        updateLonghaulStateInfo();

        attachmentRemarkField.setWidth("320px");

        photoGallery.setWidthFull();
        photoGallery.getStyle()
                .set("display", "flex")
                .set("flex-wrap", "wrap")
                .set("gap", "var(--lumo-space-m)");

        acceptanceProtocolDetailTextField.setWidthFull();
        acceptanceProtocolDetailTextField.setReadOnly(true);
        acceptanceProtocolDetailTextField.setMinHeight("180px");

        acceptanceProtocolOverview.setWidthFull();
        acceptanceProtocolOverview.getStyle()
                .set("display", "flex")
                .set("flex-direction", "column")
                .set("gap", "var(--lumo-space-xs)");

        acceptanceProtocolSignature.setWidth("260px");
        acceptanceProtocolSignature.setHeight("140px");
        acceptanceProtocolSignature.getStyle()
                .set("display", "flex")
                .set("align-items", "center")
                .set("justify-content", "center")
                .set("border", "1px solid var(--lumo-contrast-20pct)")
                .set("border-radius", "var(--lumo-border-radius-l)")
                .set("background", "var(--lumo-base-color)");

        acceptanceProtocolPdfLink.setText("Abnahmeprotokoll PDF");
        acceptanceProtocolPdfLink.setTarget("_blank");
        acceptanceProtocolPdfLink.getElement().setAttribute("download", true);

        mediaAcceptanceProtocolPdfLink.setText("Abnahmeprotokoll PDF");
        mediaAcceptanceProtocolPdfLink.setTarget("_blank");
        mediaAcceptanceProtocolPdfLink.getElement().setAttribute("download", true);
    }

    private void configureArticleGrid() {
        articleGrid.addColumn(ArticleRow::tourLabel).setHeader("Station").setAutoWidth(true);
        articleGrid.addColumn(row -> nullableLong(row.articleTypeId())).setHeader("Artikeltyp").setAutoWidth(true);
        articleGrid.addColumn(row -> nullableInteger(row.quantity())).setHeader("Menge").setAutoWidth(true);
        articleGrid.addColumn(row -> nullableInteger(row.packingPieces())).setHeader("Packstuecke").setAutoWidth(true);
        articleGrid.addColumn(row -> nullableBigDecimal(row.weight())).setHeader("Gewicht").setAutoWidth(true);
        articleGrid.addColumn(row -> nullableBigDecimal(row.price())).setHeader("Preis").setAutoWidth(true);
        articleGrid.setWidthFull();
        articleGrid.setMinHeight("260px");
        articleGrid.asSingleSelect().addValueChangeListener(event -> refreshArticleActions());

        addArticleButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
        addArticleButton.addClickListener(event -> openArticleDialog(null));

        editArticleButton.addClickListener(event -> {
            ArticleRow selectedRow = articleGrid.asSingleSelect().getValue();
            if (selectedRow != null) {
                openArticleDialog(selectedRow);
            }
        });

        deleteArticleButton.addThemeVariants(ButtonVariant.LUMO_ERROR);
        deleteArticleButton.addClickListener(event -> deleteSelectedArticle());
        refreshArticleActions();
    }

    private void configureJobPriceGrid() {
        jobPriceGrid.addColumn(JobPriceRow::categoryLabel).setHeader("Preiskategorie").setAutoWidth(true);
        jobPriceGrid.addColumn(JobPriceRow::remark).setHeader("Service").setFlexGrow(1);
        jobPriceGrid.addColumn(row -> nullableLong(row.reference())).setHeader("Referenz").setAutoWidth(true);
        jobPriceGrid.addColumn(row -> nullableBigDecimal(row.price())).setHeader("Preis").setAutoWidth(true);
        jobPriceGrid.setWidthFull();
        jobPriceGrid.setMinHeight("260px");
        jobPriceGrid.asSingleSelect().addValueChangeListener(event -> refreshJobPriceActions());

        addJobPriceButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
        addJobPriceButton.addClickListener(event -> openJobPriceDialog(null));

        editJobPriceButton.addClickListener(event -> {
            JobPriceRow selectedRow = jobPriceGrid.asSingleSelect().getValue();
            if (selectedRow != null) {
                openJobPriceDialog(selectedRow);
            }
        });

        deleteJobPriceButton.addThemeVariants(ButtonVariant.LUMO_ERROR);
        deleteJobPriceButton.addClickListener(event -> deleteSelectedJobPrice());

        recalculateLegacyPricingButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
        recalculateLegacyPricingButton.addClickListener(event -> recalculateLegacyPricing());
        refreshJobPriceGrid();
    }

    private void configurePersistedTourServiceGrid() {
        persistedTourServiceGrid.addColumn(PersistedTourServiceRow::costCenterLabel).setHeader("Kostenstelle").setAutoWidth(true);
        persistedTourServiceGrid.addColumn(PersistedTourServiceRow::tourLabel).setHeader("Station").setAutoWidth(true);
        persistedTourServiceGrid.addColumn(PersistedTourServiceRow::serviceName).setHeader("Leistung").setFlexGrow(1);
        persistedTourServiceGrid.addColumn(PersistedTourServiceRow::serviceTypeName).setHeader("Typ").setAutoWidth(true);
        persistedTourServiceGrid.addColumn(row -> nullableBigDecimal(row.price())).setHeader("Preis").setAutoWidth(true);
        persistedTourServiceGrid.addColumn(row -> nullableBigDecimal(row.discount())).setHeader("Rabatt %").setAutoWidth(true);
        persistedTourServiceGrid.addColumn(row -> nullableBigDecimal(row.businessVolume())).setHeader("Betrag").setAutoWidth(true);
        persistedTourServiceGrid.setWidthFull();
        persistedTourServiceGrid.setMinHeight("240px");
        refreshPersistedTourServiceGrid();
    }

    private void configureAttachmentGrid() {
        attachmentGrid.addColumn(AttachmentRow::fileName).setHeader("Datei").setFlexGrow(1);
        attachmentGrid.addColumn(AttachmentRow::remark).setHeader("Bemerkung").setFlexGrow(1);
        attachmentGrid.addColumn(AttachmentRow::source).setHeader("Quelle").setAutoWidth(true);
        attachmentGrid.addColumn(AttachmentRow::modifiedAt).setHeader("Geaendert").setAutoWidth(true);
        attachmentGrid.addColumn(row -> formatBytes(row.sizeBytes())).setHeader("Groesse").setAutoWidth(true);
        attachmentGrid.addComponentColumn(this::buildAttachmentActions).setHeader("Aktionen").setAutoWidth(true);
        attachmentGrid.setWidthFull();
        attachmentGrid.setMinHeight("260px");
    }

    private void configureAcceptanceProtocolGrid() {
        acceptanceProtocolGrid.addColumn(row -> nullableInteger(row.sort())).setHeader("Nr.").setAutoWidth(true);
        acceptanceProtocolGrid.addColumn(AcceptanceProtocolQuestionRow::question).setHeader("Frage").setFlexGrow(1);
        acceptanceProtocolGrid.addColumn(AcceptanceProtocolQuestionRow::answer).setHeader("Antwort").setAutoWidth(true);
        acceptanceProtocolGrid.addColumn(AcceptanceProtocolQuestionRow::article).setHeader("Artikel").setAutoWidth(true);
        acceptanceProtocolGrid.addColumn(AcceptanceProtocolQuestionRow::remark).setHeader("Bemerkung").setFlexGrow(1);
        acceptanceProtocolGrid.addColumn(AcceptanceProtocolQuestionRow::mailState).setHeader("Mailregel").setAutoWidth(true);
        acceptanceProtocolGrid.setWidthFull();
        acceptanceProtocolGrid.setMinHeight("260px");
    }

    private void configureUpload() {
        documentUpload.setMaxFiles(1);
        documentUpload.setMaxFileSize(25 * 1024 * 1024);
        documentUpload.setDropAllowed(true);
        documentUpload.setUploadButton(new Button("Dokument hochladen", VaadinIcon.UPLOAD.create()));
        documentUpload.addSucceededListener(event -> {
            try {
                uploadAttachment(event.getFileName(), event.getMIMEType());
            } catch (Exception ex) {
                showError("Upload fehlgeschlagen: " + ex.getMessage());
            } finally {
                documentUpload.clearFileList();
            }
        });
        documentUpload.addFileRejectedListener(event -> showError(event.getErrorMessage()));
    }

    private void buildForm() {
        ensureJobPriceTypesLoaded();
        ensureJobServiceOptionsLoaded();
        title = new H3();

        Tab orderTab = new Tab("Auftrag");
        Tab pickupTab = new Tab("Abholung");
        Tab deliveryTab = new Tab("Zustellung");
        Tab articleTab = new Tab("Artikel");
        Tab calculatorTab = new Tab("Jobcalculator");
        Tab invoiceTab = new Tab("Rechnung");
        Tab protocolTab = new Tab("Protokoll");
        Tab mediaTab = new Tab("Medien");

        Tabs tabs = new Tabs(orderTab, pickupTab, deliveryTab, articleTab, calculatorTab, invoiceTab, protocolTab, mediaTab);

        VerticalLayout orderLayout = buildOrderLayout();
        VerticalLayout pickupLayout = buildTourLayout(pickupEditor);
        VerticalLayout deliveryLayout = buildTourLayout(deliveryEditor);
        VerticalLayout articleLayout = buildArticleLayout();
        VerticalLayout calculatorLayout = buildJobCalculatorLayout();
        VerticalLayout invoiceLayout = buildInvoiceLayout();
        VerticalLayout protocolLayout = buildAcceptanceProtocolLayout();
        VerticalLayout mediaLayout = buildMediaLayout();

        List<Component> sections = List.of(orderLayout, pickupLayout, deliveryLayout, articleLayout, calculatorLayout, invoiceLayout, protocolLayout, mediaLayout);
        sections.forEach(section -> section.setVisible(false));
        orderLayout.setVisible(true);

        tabs.addSelectedChangeListener(event -> {
            orderLayout.setVisible(event.getSelectedTab() == orderTab);
            pickupLayout.setVisible(event.getSelectedTab() == pickupTab);
            deliveryLayout.setVisible(event.getSelectedTab() == deliveryTab);
            articleLayout.setVisible(event.getSelectedTab() == articleTab);
            calculatorLayout.setVisible(event.getSelectedTab() == calculatorTab);
            invoiceLayout.setVisible(event.getSelectedTab() == invoiceTab);
            protocolLayout.setVisible(event.getSelectedTab() == protocolTab);
            mediaLayout.setVisible(event.getSelectedTab() == mediaTab);
        });

        Button saveButton = new Button("Speichern", VaadinIcon.CHECK.create(), event -> save());
        saveButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
        saveButton.setEnabled(sessionData.canManageJobs());
        Button cancelButton = new Button("Abbrechen", event ->
                getUI().ifPresent(ui -> ui.navigate(JobListView.class)));

        HorizontalLayout buttons = new HorizontalLayout(saveButton, cancelButton);
        add(title, tabs, orderLayout, pickupLayout, deliveryLayout, articleLayout, calculatorLayout, invoiceLayout, protocolLayout, mediaLayout, buttons);
    }

    private VerticalLayout buildOrderLayout() {
        FormLayout form = new FormLayout();
        form.setResponsiveSteps(
                new FormLayout.ResponsiveStep("0", 1),
                new FormLayout.ResponsiveStep("720px", 2),
                new FormLayout.ResponsiveStep("1100px", 3)
        );
        VerticalLayout longhaulLayout = new VerticalLayout(manualLonghaulField, longhaulStateInfo);
        longhaulLayout.setPadding(false);
        longhaulLayout.setSpacing(false);
        longhaulLayout.setAlignItems(Alignment.START);
        form.add(
                statusField,
                orderTimeField,
                finishTimeField,
                tourNameField,
                courierIdField,
                courierSidField,
                vehicleTypeIdField,
                payerCostCenterField,
                relatedCostCenterField,
                totalPriceField,
                courierPriceField,
                fixPriceField,
                markupField,
                longhaulLayout
        );
        form.add(servicesField);
        form.setColspan(servicesField, 3);

        VerticalLayout layout = new VerticalLayout(
                new Paragraph("Grunddaten des Auftrags inklusive Status, Tourbezug, Leistungen und abrechnungsrelevanter Kennzahlen."),
                form
        );
        layout.setPadding(false);
        layout.setSpacing(true);
        return layout;
    }

    private VerticalLayout buildTourLayout(TourEditor editor) {
        H4 heading = new H4(editor.caption);

        FormLayout form = new FormLayout();
        form.setResponsiveSteps(
                new FormLayout.ResponsiveStep("0", 1),
                new FormLayout.ResponsiveStep("720px", 2)
        );
        form.add(
                editor.companyField,
                editor.personField,
                editor.streetField,
                editor.hsnoField,
                editor.zipcodeField,
                editor.cityField,
                editor.commissionField,
                editor.phoneField,
                editor.statusField,
                editor.costCenterIdField,
                editor.remarkField
        );

        VerticalLayout layout = new VerticalLayout(heading, form);
        layout.setPadding(false);
        layout.setSpacing(true);
        return layout;
    }

    private VerticalLayout buildArticleLayout() {
        HorizontalLayout actions = new HorizontalLayout(addArticleButton, editArticleButton, deleteArticleButton);
        VerticalLayout layout = new VerticalLayout(
                new Paragraph("Artikel werden gesammelt mit dem Auftrag gespeichert. Bestehende Auftraege erhalten beim Speichern den kompletten Artikelsatz neu."),
                actions,
                articleGrid
        );
        layout.setPadding(false);
        layout.setSpacing(true);
        return layout;
    }

    private VerticalLayout buildJobCalculatorLayout() {
        FormLayout pricingSummary = new FormLayout();
        pricingSummary.setResponsiveSteps(
                new FormLayout.ResponsiveStep("0", 1),
                new FormLayout.ResponsiveStep("720px", 2)
        );
        pricingSummary.add(
                subtotalPriceField,
                servicePriceField,
                courierSubtotalField,
                selfServiceDiscountField
        );

        HorizontalLayout actions = new HorizontalLayout(
                recalculateLegacyPricingButton,
                addJobPriceButton,
                editJobPriceButton,
                deleteJobPriceButton
        );
        VerticalLayout layout = new VerticalLayout(
                new Paragraph("Preispositionen zum Auftrag analog zum Legacy-Jobcalculator inklusive Fixpreis-, Rabatt-, Servicepreis- und Selbsterfasser-Logik. Darunter werden die bereits persistent gespeicherten `tourservice`-Zeilen read-only angezeigt."),
                pricingSummary,
                actions,
                jobPriceTotalLabel,
                jobPriceGrid,
                new H4("Persistierte Servicekosten"),
                persistedTourServiceTotalLabel,
                persistedTourServiceGrid
        );
        layout.setPadding(false);
        layout.setSpacing(true);
        return layout;
    }

    private VerticalLayout buildInvoiceLayout() {
        VerticalLayout layout = new VerticalLayout(
                new Paragraph("Freier Rechnungstext analog zur PHP-Anwendung."),
                invoiceTextField
        );
        layout.setPadding(false);
        layout.setSpacing(true);
        return layout;
    }

    private VerticalLayout buildAcceptanceProtocolLayout() {
        Button refreshButton = new Button("Protokoll neu laden", event -> loadAcceptanceProtocol());
        HorizontalLayout actions = new HorizontalLayout(acceptanceProtocolPdfLink, refreshButton);
        actions.setAlignItems(Alignment.CENTER);

        VerticalLayout layout = new VerticalLayout(
                new Paragraph("Abnahmeprotokoll aus Kundenkonfiguration, mobilen Antworten, Signatur und Versandstatus des gespeicherten Auftrags."),
                actions,
                acceptanceProtocolOverview,
                acceptanceProtocolDetailTextField,
                new H4("Fragen"),
                acceptanceProtocolGrid,
                new H4("Unterschrift"),
                acceptanceProtocolSignature
        );
        layout.setPadding(false);
        layout.setSpacing(true);
        return layout;
    }

    private VerticalLayout buildMediaLayout() {
        Button refreshButton = new Button("Medien neu laden", event -> loadMedia());

        HorizontalLayout uploadBar = new HorizontalLayout(attachmentRemarkField, documentUpload, refreshButton);
        uploadBar.setAlignItems(Alignment.END);
        HorizontalLayout systemDocuments = new HorizontalLayout(mediaAcceptanceProtocolPdfLink);
        systemDocuments.setAlignItems(Alignment.CENTER);

        boolean mediaEnabled = jobId != null && sessionData.canManageJobs();
        attachmentRemarkField.setEnabled(mediaEnabled);
        documentUpload.setVisible(mediaEnabled);

        String infoText = mediaEnabled
                ? "Dokumente werden revisionssicher im Backend abgelegt. Fotos werden aus mobilen Uploads und Stationsfotos gelesen."
                : "Dokumente und Fotos koennen nach dem ersten Speichern des Auftrags gepflegt werden.";

        VerticalLayout layout = new VerticalLayout(
                new Paragraph(infoText),
                uploadBar,
                new H4("Systemdokumente"),
                systemDocuments,
                new H4("Dokumente"),
                attachmentGrid,
                new H4("Fotos"),
                photoGallery
        );
        layout.setPadding(false);
        layout.setSpacing(true);
        return layout;
    }

    private void updateTitle() {
        if (title != null) {
            title.setText(isNew ? "Neuer Auftrag" : "Auftrag bearbeiten: " + jobId);
        }
    }

    private void loadJob() {
        try {
            @SuppressWarnings("unchecked")
            Map<String, Object> job = restClient.get("/jobs/" + jobId, LinkedHashMap.class);
            if (job == null || job.isEmpty()) {
                showError("Auftrag wurde nicht gefunden.");
                return;
            }

            applyJobValues(job);
            loadTours();
            loadArticles();
            loadJobPrices();
            loadPersistedTourServices();
            loadAcceptanceProtocol();
            loadMedia();
        } catch (Exception e) {
            showError("Fehler beim Laden: " + e.getMessage());
        }
    }

    private void loadTours() {
        pickupEditor.clear(1);
        deliveryEditor.clear(2);

        Object[] result = restClient.get("/jobs/" + jobId + "/tours", Object[].class);
        List<Map<String, Object>> tours = toMapList(result);
        tours.sort(Comparator.comparingInt(row -> toInteger(row.get("sort"), Integer.MAX_VALUE)));

        if (!tours.isEmpty()) {
            pickupEditor.load(tours.get(0), 1);
        }
        if (tours.size() > 1) {
            deliveryEditor.load(tours.get(1), 2);
        }
    }

    private void loadArticles() {
        articleRows.clear();
        Object[] result = restClient.get("/jobs/" + jobId + "/articles", Object[].class);
        for (Map<String, Object> row : toMapList(result)) {
            Integer tourSort = toInteger(row.get("tourSort"), pickupEditor.sort);
            articleRows.add(new ArticleRow(
                    toLong(row.get("id")),
                    toLong(row.get("articleTypeId")),
                    toInteger(row.get("quantity"), null),
                    toInteger(row.get("packingPieces"), null),
                    toBigDecimal(row.get("weight")),
                    toBigDecimal(row.get("price")),
                    tourSort,
                    tourLabel(tourSort)
            ));
        }
        refreshArticleGrid();
    }

    private void ensureJobPriceTypesLoaded() {
        if (!jobPriceTypeOptions.isEmpty()) {
            return;
        }

        try {
            List<LinkedHashMap<String, Object>> result = restClient.getList(
                    "/metatypes/type/job_price",
                    new ParameterizedTypeReference<>() {}
            );
            for (Map<String, Object> row : result) {
                Integer sort = toInteger(row.get("sort"), null);
                if (sort == null) {
                    continue;
                }
                String internalName = str(row.get("value"));
                String categoryLabel = str(row.get("mappedValue"));
                if (categoryLabel.isBlank()) {
                    categoryLabel = internalName;
                }
                if (categoryLabel.isBlank()) {
                    categoryLabel = "Kategorie " + sort;
                }
                JobPriceTypeOption option = new JobPriceTypeOption(sort, internalName, categoryLabel);
                jobPriceTypeOptions.add(option);
                jobPriceTypeBySort.put(sort, option);
            }
            jobPriceTypeOptions.sort(Comparator.comparing(JobPriceTypeOption::categoryLabel, String.CASE_INSENSITIVE_ORDER));
        } catch (Exception e) {
            showError("Preiskategorien konnten nicht geladen werden: " + e.getMessage());
        }
    }

    private void loadJobPrices() {
        applyJobPriceRows(loadJobPriceMaps());
    }

    private void loadPersistedTourServices() {
        persistedTourServiceRows.clear();
        if (jobId == null) {
            refreshPersistedTourServiceGrid();
            return;
        }

        try {
            List<LinkedHashMap<String, Object>> result = restClient.getList(
                    "/jobs/" + jobId + "/tourservices",
                    new ParameterizedTypeReference<>() {}
            );
            for (Map<String, Object> row : result) {
                Integer tourSort = toInteger(row.get("tourSort"), null);
                persistedTourServiceRows.add(new PersistedTourServiceRow(
                        toLong(row.get("costCenterId")),
                        str(row.get("costCenterName")),
                        tourSort,
                        persistedTourSortLabel(tourSort),
                        str(row.get("serviceName")),
                        str(row.get("serviceTypeName")),
                        toBigDecimal(row.get("price")),
                        toBigDecimal(row.get("discount")),
                        toBigDecimal(row.get("businessVolume"))
                ));
            }
            refreshPersistedTourServiceGrid();
        } catch (Exception e) {
            showError("Persistierte Servicekosten konnten nicht geladen werden: " + e.getMessage());
        }
    }

    private List<Map<String, Object>> loadJobPriceMaps() {
        jobPriceRows.clear();
        if (jobId == null) {
            refreshJobPriceGrid();
            return List.of();
        }

        ensureJobPriceTypesLoaded();
        return new ArrayList<>(restClient.getList(
                "/jobs/" + jobId + "/prices",
                new ParameterizedTypeReference<>() {}
        ));
    }

    private void applyJobPriceRows(List<Map<String, Object>> rows) {
        jobPriceRows.clear();
        BigDecimal fallbackSelfServiceDiscount = null;
        BigDecimal fallbackCourierSubtotal = null;
        BigDecimal fallbackMarkup = null;

        for (Map<String, Object> row : rows) {
            Integer metaSort = toInteger(row.get("metaSort"), null);
            JobPriceTypeOption option = jobPriceTypeFor(metaSort);
            BigDecimal price = toBigDecimal(row.get("price"));
            jobPriceRows.add(new JobPriceRow(
                    metaSort,
                    option != null ? option.internalName() : "",
                    option != null ? option.categoryLabel() : categoryLabel(metaSort),
                    price,
                    str(row.get("remark")),
                    toLong(row.get("reference"))
            ));
            if (Objects.equals(metaSort, JB_SELF_SERVICE_DISCOUNT)) {
                fallbackSelfServiceDiscount = price;
            } else if (Objects.equals(metaSort, JB_CR_SUBTOTAL)) {
                fallbackCourierSubtotal = price;
            } else if (Objects.equals(metaSort, JB_MARKUP_MAN)) {
                fallbackMarkup = price;
            }
        }

        if (fallbackSelfServiceDiscount != null) {
            selfServiceDiscountField.setValue(nullableBigDecimal(fallbackSelfServiceDiscount));
        } else if (selfServiceDiscountField.getValue().isBlank()) {
            selfServiceDiscountField.clear();
        }
        if (fallbackCourierSubtotal != null && courierSubtotalField.getValue().isBlank()) {
            courierSubtotalField.setValue(nullableBigDecimal(fallbackCourierSubtotal));
        }
        if (fallbackMarkup != null && markupField.getValue().isBlank()) {
            markupField.setValue(nullableBigDecimal(fallbackMarkup));
        }
        refreshJobPriceGrid();
    }

    private void loadMedia() {
        if (jobId == null) {
            attachmentRows.clear();
            attachmentGrid.setItems(attachmentRows);
            photoRows.clear();
            refreshPhotoGallery();
            updateAcceptanceProtocolDownloadLinks(false);
            return;
        }

        try {
            attachmentRows.clear();
            Object[] attachmentResult = restClient.get("/jobs/" + jobId + "/attachments", Object[].class);
            for (Map<String, Object> row : toMapList(attachmentResult)) {
                attachmentRows.add(new AttachmentRow(
                        toLong(row.get("id")),
                        str(row.get("fileName")),
                        str(row.get("remark")),
                        str(row.get("source")),
                        str(row.get("modifiedAt")),
                        toLong(row.get("sizeBytes")),
                        Boolean.TRUE.equals(row.get("downloadable")),
                        Boolean.TRUE.equals(row.get("deletable"))
                ));
            }
            attachmentGrid.setItems(attachmentRows);

            photoRows.clear();
            Object[] photoResult = restClient.get("/jobs/" + jobId + "/photos", Object[].class);
            for (Map<String, Object> row : toMapList(photoResult)) {
                photoRows.add(new PhotoRow(
                        str(row.get("fileName")),
                        str(row.get("source")),
                        Boolean.TRUE.equals(row.get("downloadable"))
                ));
            }
            refreshPhotoGallery();
        } catch (Exception e) {
            showError("Medien konnten nicht geladen werden: " + e.getMessage());
        }
    }

    private void loadAcceptanceProtocol() {
        if (jobId == null) {
            acceptanceProtocolOverview.removeAll();
            acceptanceProtocolOverview.add(new Span("Abnahmeprotokolle stehen nach dem ersten Speichern des Auftrags zur Verfuegung."));
            acceptanceProtocolDetailTextField.clear();
            acceptanceProtocolGrid.setItems(List.of());
            acceptanceProtocolSignature.removeAll();
            acceptanceProtocolSignature.add(new Span("Keine Signatur vorhanden."));
            updateAcceptanceProtocolDownloadLinks(false);
            return;
        }

        try {
            @SuppressWarnings("unchecked")
            Map<String, Object> protocol = restClient.get("/jobs/" + jobId + "/acceptance-protocol", LinkedHashMap.class);
            applyAcceptanceProtocol(protocol != null ? protocol : Map.of());
        } catch (Exception e) {
            showError("Abnahmeprotokoll konnte nicht geladen werden: " + e.getMessage());
        }
    }

    private void applyAcceptanceProtocol(Map<String, Object> protocol) {
        boolean featureEnabled = Boolean.TRUE.equals(protocol.get("featureEnabled"));
        boolean dataAvailable = Boolean.TRUE.equals(protocol.get("dataAvailable"));
        boolean signatureAvailable = Boolean.TRUE.equals(protocol.get("signatureAvailable"));

        acceptanceProtocolOverview.removeAll();
        acceptanceProtocolOverview.add(new Span("Status: " + (featureEnabled ? "aktiv" : "per Parameter deaktiviert")));
        acceptanceProtocolOverview.add(new Span("Services am Auftrag: " + joinLabels(protocol.get("selectedServices"))));
        acceptanceProtocolOverview.add(new Span("Protokoll-Service: " + str(protocol.get("protocolServiceLabel"))));
        acceptanceProtocolOverview.add(new Span("Packstuecke: " + nullableInteger(toInteger(protocol.get("packingPieces"), null))));
        acceptanceProtocolOverview.add(new Span("Mobile Fotos: " + nullableInteger(toInteger(protocol.get("mobilePhotoCount"), 0))));
        acceptanceProtocolOverview.add(new Span("Mailstatus: " + str(protocol.get("mailStatus"))));
        acceptanceProtocolOverview.add(new Span("Briefstatus: " + str(protocol.get("letterStatus"))));
        if (!str(protocol.get("resolutionNotice")).isBlank()) {
            acceptanceProtocolOverview.add(new Span(str(protocol.get("resolutionNotice"))));
        }
        if (!dataAvailable) {
            acceptanceProtocolOverview.add(new Span("Zum gespeicherten Auftrag liegen aktuell noch keine Protokolldaten vor."));
        }

        acceptanceProtocolDetailTextField.setValue(str(protocol.get("text")));

        List<AcceptanceProtocolQuestionRow> questionRows = new ArrayList<>();
        Object rawQuestions = protocol.get("questions");
        if (rawQuestions instanceof List<?> list) {
            for (Map<String, Object> row : toMapList(list.toArray())) {
                questionRows.add(new AcceptanceProtocolQuestionRow(
                        toInteger(row.get("sort"), null),
                        str(row.get("question")),
                        str(row.get("answer")),
                        str(row.get("article")),
                        str(row.get("remark")),
                        str(row.get("mailState"))
                ));
            }
        }
        acceptanceProtocolGrid.setItems(questionRows);

        acceptanceProtocolSignature.removeAll();
        if (signatureAvailable) {
            Image signature = new Image(acceptanceProtocolSignatureResource(), "Unterschrift");
            signature.setMaxWidth("240px");
            signature.setMaxHeight("120px");
            acceptanceProtocolSignature.add(signature);
        } else {
            acceptanceProtocolSignature.add(new Span("Keine Signatur vorhanden."));
        }

        updateAcceptanceProtocolDownloadLinks(featureEnabled);
    }

    private void refreshPhotoGallery() {
        photoGallery.removeAll();
        if (photoRows.isEmpty()) {
            photoGallery.add(new Span("Keine Fotos vorhanden."));
            return;
        }

        for (PhotoRow row : photoRows) {
            photoGallery.add(buildPhotoCard(row));
        }
    }

    private Component buildPhotoCard(PhotoRow row) {
        VerticalLayout card = new VerticalLayout();
        card.setPadding(false);
        card.setSpacing(false);
        card.setWidth("220px");
        card.getStyle()
                .set("border", "1px solid var(--lumo-contrast-20pct)")
                .set("border-radius", "var(--lumo-border-radius-l)")
                .set("overflow", "hidden");

        if (row.downloadable()) {
            Image image = new Image(photoResource(row.fileName()), row.fileName());
            image.setWidth("220px");
            image.setHeight("160px");
            image.getStyle().set("object-fit", "cover");
            card.add(image);
        } else {
            Div placeholder = new Div();
            placeholder.setText("Foto nicht verfuegbar");
            placeholder.setWidthFull();
            placeholder.setHeight("160px");
            placeholder.getStyle()
                    .set("display", "flex")
                    .set("align-items", "center")
                    .set("justify-content", "center")
                    .set("background", "var(--lumo-contrast-5pct)");
            card.add(placeholder);
        }

        VerticalLayout meta = new VerticalLayout();
        meta.setPadding(true);
        meta.setSpacing(false);
        meta.add(new Span(row.fileName()), new Span("Quelle: " + row.source()));

        if (row.downloadable()) {
            Anchor open = new Anchor(photoResource(row.fileName()), "Oeffnen");
            open.setTarget("_blank");
            meta.add(open);
        }

        card.add(meta);
        return card;
    }

    private Component buildAttachmentActions(AttachmentRow row) {
        HorizontalLayout actions = new HorizontalLayout();
        actions.setPadding(false);
        actions.setSpacing(true);

        if (row.downloadable()) {
            Anchor download = new Anchor(attachmentResource(row.fileName()), "Download");
            download.getElement().setAttribute("download", true);
            actions.add(download);
        } else {
            actions.add(new Span("Nicht verfuegbar"));
        }

        if (sessionData.canManageJobs() && row.deletable() && row.id() != null) {
            Button deleteButton = new Button(VaadinIcon.TRASH.create(), event -> deleteAttachment(row));
            deleteButton.addThemeVariants(ButtonVariant.LUMO_ERROR, ButtonVariant.LUMO_TERTIARY_INLINE);
            actions.add(deleteButton);
        }

        return actions;
    }

    private void deleteAttachment(AttachmentRow row) {
        if (!sessionData.canManageJobs()) {
            return;
        }
        if (jobId == null || row.id() == null) {
            return;
        }
        try {
            restClient.delete("/jobs/" + jobId + "/attachments/" + row.id());
            loadMedia();
            Notification.show("Dokument geloescht.", 2500, Notification.Position.MIDDLE);
        } catch (Exception e) {
            showError("Dokument konnte nicht geloescht werden: " + e.getMessage());
        }
    }

    private void uploadAttachment(String fileName, String mimeType) {
        if (!sessionData.canManageJobs()) {
            showError("Keine Berechtigung zum Hochladen von Dokumenten.");
            return;
        }
        if (jobId == null) {
            showError("Auftrag zuerst speichern.");
            return;
        }

        try {
            byte[] bytes = uploadBuffer.getInputStream().readAllBytes();
            restClient.postMultipart(
                    "/jobs/" + jobId + "/attachments",
                    bytes,
                    fileName,
                    mimeType,
                    Map.of("remark", attachmentRemarkField.getValue()),
                    LinkedHashMap.class
            );
            attachmentRemarkField.clear();
            loadMedia();
            Notification.show("Dokument gespeichert.", 2500, Notification.Position.MIDDLE);
        } catch (IOException e) {
            throw new RuntimeException("Datei konnte nicht gelesen werden", e);
        }
    }

    private void save() {
        if (!sessionData.canManageJobs()) {
            showError("Keine Berechtigung zum Bearbeiten von Auftraegen.");
            return;
        }
        boolean jobPersisted = false;
        Long effectiveJobId = jobId;
        try {
            if (!validateCostCenterAccess()) {
                return;
            }
            Map<String, Object> request = new HashMap<>();
            request.put("job", buildJobPayload());
            request.put("tours", List.of(pickupEditor.toRequest(), deliveryEditor.toRequest()));
            request.put("articles", articleRows.stream().map(this::toArticlePayload).toList());

            if (isNew) {
                request.put("prices", buildManagedJobPriceRows().stream().map(this::toJobPricePayload).toList());
                @SuppressWarnings("unchecked")
                Map<String, Object> created = restClient.post("/jobs", request, LinkedHashMap.class);
                Long createdId = toLong(created.get("id"));
                if (createdId == null) {
                    throw new RuntimeException("Neue Auftrags-ID fehlt in der Antwort.");
                }
                effectiveJobId = createdId;
                jobPersisted = true;
                Notification.show("Auftrag gespeichert.", 2500, Notification.Position.MIDDLE);
                getUI().ifPresent(ui -> ui.navigate("jobs/" + createdId));
            } else {
                restClient.put("/jobs/" + jobId + "/details", request);
                effectiveJobId = jobId;
                jobPersisted = true;
                persistJobPrices(jobId);
                loadJob();
                Notification.show("Auftrag gespeichert.", 2500, Notification.Position.MIDDLE);
            }
        } catch (Exception e) {
            if (jobPersisted && effectiveJobId != null) {
                showError("Auftrag wurde gespeichert, der Jobcalculator aber nicht vollstaendig synchronisiert: " + e.getMessage());
            } else {
                showError("Speichern fehlgeschlagen: " + e.getMessage());
            }
        }
    }

    private boolean validateCostCenterAccess() {
        if (!sessionData.isCustomerUser()) {
            return true;
        }

        List<Long> referencedCostCenters = new ArrayList<>();
        referencedCostCenters.add(toLong(payerCostCenterField.getValue()));
        referencedCostCenters.add(toLong(relatedCostCenterField.getValue()));
        referencedCostCenters.add(toLong(pickupEditor.costCenterIdField.getValue()));
        referencedCostCenters.add(toLong(deliveryEditor.costCenterIdField.getValue()));

        for (Long costCenterId : referencedCostCenters) {
            if (costCenterId != null && !sessionData.hasCostCenterAccess(costCenterId)) {
                showError("Diese Kostenstelle ist fuer den angemeldeten Kundenbenutzer nicht freigegeben.");
                return false;
            }
        }
        return true;
    }

    private Map<String, Object> buildJobPayload() {
        Map<String, Object> job = new HashMap<>();
        job.put("status", statusField.getValue() != null ? statusField.getValue().id() : 0);
        job.put("orderTime", orderTimeField.getValue());
        job.put("finishTime", finishTimeField.getValue());
        job.put("headquartersId", sessionData.getHeadquartersId());
        job.put("tourName", tourNameField.getValue());
        job.put("courierId", toLong(courierIdField.getValue()));
        job.put("courierSid", courierSidField.getValue());
        job.put("vehicleTypeId", toInteger(vehicleTypeIdField.getValue(), null));
        job.put("costCenterPayerId", toLong(payerCostCenterField.getValue()));
        job.put("costCenterRelatedId", toLong(relatedCostCenterField.getValue()));
        job.put("totalPrice", toBigDecimal(totalPriceField.getValue()));
        job.put("subtotalPrice", toBigDecimal(subtotalPriceField.getValue()));
        job.put("courierPrice", toBigDecimal(courierPriceField.getValue()));
        job.put("courierSubprice", toBigDecimal(courierSubtotalField.getValue()));
        job.put("fixPrice", toBigDecimal(fixPriceField.getValue()));
        job.put("servicePrice", toBigDecimal(servicePriceField.getValue()));
        job.put("markup", toBigDecimal(markupField.getValue()));
        Integer manualLonghaul = buildManualLonghaulPayload();
        if (manualLonghaul != null) {
            job.put("longhaul", manualLonghaul);
        }
        job.put("service", encodeJobServices(servicesField.getValue()));
        job.put("invoiceText", invoiceTextField.getValue());
        return job;
    }

    private Map<String, Object> toArticlePayload(ArticleRow row) {
        Map<String, Object> article = new HashMap<>();
        article.put("articleTypeId", row.articleTypeId());
        article.put("quantity", row.quantity());
        article.put("packingPieces", row.packingPieces());
        article.put("weight", row.weight());
        article.put("price", row.price());
        article.put("tourSort", row.tourSort());
        return article;
    }

    private Map<String, Object> toJobPricePayload(JobPriceRow row) {
        Map<String, Object> price = new HashMap<>();
        price.put("metaSort", row.metaSort());
        price.put("price", row.price());
        price.put("remark", row.remark());
        price.put("reference", row.reference());
        return price;
    }

    private void persistJobPrices(Long effectiveJobId) {
        List<JobPriceRow> persistedRows = buildManagedJobPriceRows();
        jobPriceRows.clear();
        jobPriceRows.addAll(persistedRows);
        refreshJobPriceGrid();
        restClient.put(
                "/jobs/" + effectiveJobId + "/prices",
                persistedRows.stream().map(this::toJobPricePayload).toList()
        );
    }

    private void recalculateLegacyPricing() {
        if (!sessionData.canManageJobs()) {
            showError("Keine Berechtigung zur Preisberechnung.");
            return;
        }
        try {
            @SuppressWarnings("unchecked")
            Map<String, Object> result = restClient.post("/jobs/pricing/calculate", buildPricingRequest(), LinkedHashMap.class);
            if (result == null || result.isEmpty()) {
                showError("Keine Antwort aus der Preisberechnung erhalten.");
                return;
            }
            Map<String, Object> job = result.get("job") instanceof Map<?, ?> jobMap
                    ? castMap(jobMap)
                    : Map.of();
            applyJobValues(job);

            Object[] rawPrices = result.get("prices") instanceof List<?> list ? list.toArray() : null;
            applyJobPriceRows(toMapList(rawPrices));

            Notification.show("Legacy-Preisfunktionen angewendet.", 2500, Notification.Position.MIDDLE);
        } catch (Exception e) {
            showError("Preisberechnung fehlgeschlagen: " + e.getMessage());
        }
    }

    private void openArticleDialog(ArticleRow existing) {
        if (!sessionData.canManageJobs()) {
            showError("Keine Berechtigung zur Artikelpflege.");
            return;
        }
        Dialog dialog = new Dialog();
        dialog.setHeaderTitle(existing == null ? "Artikel anlegen" : "Artikel bearbeiten");

        TextField articleTypeField = new TextField("Artikeltyp-ID");
        articleTypeField.setWidthFull();
        TextField quantityField = new TextField("Menge");
        quantityField.setWidthFull();
        TextField packingPiecesField = new TextField("Packstuecke");
        packingPiecesField.setWidthFull();
        TextField weightField = new TextField("Gewicht");
        weightField.setWidthFull();
        TextField priceField = new TextField("Preis");
        priceField.setWidthFull();
        ComboBox<ArticleTourOption> tourField = new ComboBox<>("Station");
        tourField.setItems(articleTourOptions());
        tourField.setItemLabelGenerator(ArticleTourOption::label);
        tourField.setWidthFull();

        if (existing != null) {
            articleTypeField.setValue(nullableLong(existing.articleTypeId()));
            quantityField.setValue(nullableInteger(existing.quantity()));
            packingPiecesField.setValue(nullableInteger(existing.packingPieces()));
            weightField.setValue(nullableBigDecimal(existing.weight()));
            priceField.setValue(nullableBigDecimal(existing.price()));
            tourField.setValue(findArticleTour(existing.tourSort()));
        } else {
            tourField.setValue(articleTourOptions().get(0));
        }

        Button saveButton = new Button("Speichern", event -> {
            if (tourField.getValue() == null) {
                showError("Bitte eine Station waehlen.");
                return;
            }

            ArticleRow newRow = new ArticleRow(
                    existing != null ? existing.id() : null,
                    toLong(articleTypeField.getValue()),
                    toInteger(quantityField.getValue(), null),
                    toInteger(packingPiecesField.getValue(), null),
                    toBigDecimal(weightField.getValue()),
                    toBigDecimal(priceField.getValue()),
                    tourField.getValue().sort(),
                    tourField.getValue().label()
            );

            if (existing == null) {
                articleRows.add(newRow);
            } else {
                int index = articleRows.indexOf(existing);
                if (index >= 0) {
                    articleRows.set(index, newRow);
                }
            }
            refreshArticleGrid();
            dialog.close();
        });
        saveButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);

        Button cancelButton = new Button("Abbrechen", event -> dialog.close());

        FormLayout form = new FormLayout();
        form.setResponsiveSteps(
                new FormLayout.ResponsiveStep("0", 1),
                new FormLayout.ResponsiveStep("560px", 2)
        );
        form.add(articleTypeField, tourField, quantityField, packingPiecesField, weightField, priceField);

        dialog.add(new VerticalLayout(form, new HorizontalLayout(saveButton, cancelButton)));
        dialog.open();
    }

    private void deleteSelectedArticle() {
        if (!sessionData.canManageJobs()) {
            return;
        }
        ArticleRow selectedRow = articleGrid.asSingleSelect().getValue();
        if (selectedRow == null) {
            return;
        }
        articleRows.remove(selectedRow);
        refreshArticleGrid();
    }

    private void refreshArticleGrid() {
        articleRows.sort(Comparator
                .comparing(ArticleRow::tourSort, Comparator.nullsLast(Integer::compareTo))
                .thenComparing(ArticleRow::articleTypeId, Comparator.nullsLast(Long::compareTo)));
        articleGrid.setItems(articleRows);
        articleGrid.asSingleSelect().clear();
        refreshArticleActions();
    }

    private void refreshArticleActions() {
        boolean selected = articleGrid.asSingleSelect().getValue() != null;
        boolean editable = sessionData.canManageJobs();
        addArticleButton.setEnabled(editable);
        editArticleButton.setEnabled(editable && selected);
        deleteArticleButton.setEnabled(editable && selected);
    }

    private void openJobPriceDialog(JobPriceRow existing) {
        if (!sessionData.canManageJobs()) {
            showError("Keine Berechtigung zur Pflege des Jobcalculators.");
            return;
        }
        ensureJobPriceTypesLoaded();
        Dialog dialog = new Dialog();
        dialog.setHeaderTitle(existing == null ? "Jobcalculator-Posten anlegen" : "Jobcalculator-Posten bearbeiten");

        ComboBox<JobPriceTypeOption> categoryField = new ComboBox<>("Preiskategorie");
        categoryField.setWidthFull();
        List<JobPriceTypeOption> options = new ArrayList<>(jobPriceTypeOptions);
        JobPriceTypeOption existingType = existing != null ? jobPriceTypeFor(existing.metaSort()) : null;
        if (existingType != null && !options.contains(existingType)) {
            options.add(existingType);
        }
        options.sort(Comparator.comparing(JobPriceTypeOption::categoryLabel, String.CASE_INSENSITIVE_ORDER));
        categoryField.setItems(options);
        categoryField.setItemLabelGenerator(JobPriceTypeOption::label);

        TextField remarkField = new TextField("Service / Bemerkung");
        remarkField.setWidthFull();
        TextField referenceField = new TextField("Referenz");
        referenceField.setWidthFull();
        TextField priceField = new TextField("Preis");
        priceField.setWidthFull();

        if (existing != null) {
            categoryField.setValue(existingType);
            remarkField.setValue(existing.remark());
            referenceField.setValue(nullableLong(existing.reference()));
            priceField.setValue(nullableBigDecimal(existing.price()));
        }

        Button saveButton = new Button("Speichern", event -> {
            JobPriceTypeOption selectedType = categoryField.getValue();
            if (selectedType == null) {
                showError("Bitte eine Preiskategorie waehlen.");
                return;
            }

            boolean duplicateCategory = jobPriceRows.stream()
                    .filter(row -> existing == null || !row.equals(existing))
                    .anyMatch(row -> Objects.equals(row.metaSort(), selectedType.sort()));
            if (duplicateCategory) {
                showError("Diese Preiskategorie ist bereits vorhanden.");
                return;
            }

            JobPriceRow newRow = new JobPriceRow(
                    selectedType.sort(),
                    selectedType.internalName(),
                    selectedType.categoryLabel(),
                    toBigDecimal(priceField.getValue()),
                    remarkField.getValue(),
                    toLong(referenceField.getValue())
            );

            if (existing == null) {
                jobPriceRows.add(newRow);
            } else {
                int index = jobPriceRows.indexOf(existing);
                if (index >= 0) {
                    jobPriceRows.set(index, newRow);
                }
            }
            refreshJobPriceGrid();
            dialog.close();
        });
        saveButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);

        Button cancelButton = new Button("Abbrechen", event -> dialog.close());

        FormLayout form = new FormLayout();
        form.setResponsiveSteps(
                new FormLayout.ResponsiveStep("0", 1),
                new FormLayout.ResponsiveStep("560px", 2)
        );
        form.add(categoryField, remarkField, referenceField, priceField);

        dialog.add(new VerticalLayout(form, new HorizontalLayout(saveButton, cancelButton)));
        dialog.open();
    }

    private void deleteSelectedJobPrice() {
        if (!sessionData.canManageJobs()) {
            return;
        }
        JobPriceRow selectedRow = jobPriceGrid.asSingleSelect().getValue();
        if (selectedRow == null) {
            return;
        }
        jobPriceRows.remove(selectedRow);
        refreshJobPriceGrid();
    }

    private void refreshJobPriceGrid() {
        jobPriceRows.sort(Comparator
                .comparing(JobPriceRow::metaSort, Comparator.nullsLast(Integer::compareTo))
                .thenComparing(JobPriceRow::remark, Comparator.nullsLast(String.CASE_INSENSITIVE_ORDER)));
        jobPriceGrid.setItems(jobPriceRows);
        jobPriceGrid.asSingleSelect().clear();
        refreshJobPriceActions();

        BigDecimal total = jobPriceRows.stream()
                .map(JobPriceRow::price)
                .filter(Objects::nonNull)
                .reduce(BigDecimal.ZERO, BigDecimal::add);
        jobPriceTotalLabel.setText("Gesamt Jobcalculator: " + nullableBigDecimal(total));
    }

    private void refreshJobPriceActions() {
        boolean selected = jobPriceGrid.asSingleSelect().getValue() != null;
        boolean editable = sessionData.canManageJobs() && !jobPriceTypeOptions.isEmpty();
        recalculateLegacyPricingButton.setEnabled(sessionData.canManageJobs());
        addJobPriceButton.setEnabled(editable);
        editJobPriceButton.setEnabled(editable && selected);
        deleteJobPriceButton.setEnabled(editable && selected);
    }

    private void refreshPersistedTourServiceGrid() {
        persistedTourServiceRows.sort(Comparator
                .comparing(PersistedTourServiceRow::costCenterLabel, Comparator.nullsLast(String.CASE_INSENSITIVE_ORDER))
                .thenComparing(PersistedTourServiceRow::tourSort, Comparator.nullsLast(Integer::compareTo))
                .thenComparing(PersistedTourServiceRow::serviceName, Comparator.nullsLast(String.CASE_INSENSITIVE_ORDER))
                .thenComparing(PersistedTourServiceRow::serviceTypeName, Comparator.nullsLast(String.CASE_INSENSITIVE_ORDER)));
        persistedTourServiceGrid.setItems(persistedTourServiceRows);

        BigDecimal total = persistedTourServiceRows.stream()
                .map(PersistedTourServiceRow::businessVolume)
                .filter(Objects::nonNull)
                .reduce(BigDecimal.ZERO, BigDecimal::add);
        if (persistedTourServiceRows.isEmpty()) {
            persistedTourServiceTotalLabel.setText("Keine persistierten Servicekosten vorhanden.");
        } else {
            persistedTourServiceTotalLabel.setText("Gesamt persistiert: " + nullableBigDecimal(total));
        }
    }

    private List<ArticleTourOption> articleTourOptions() {
        return List.of(
                new ArticleTourOption(pickupEditor.sort, "Abholung"),
                new ArticleTourOption(deliveryEditor.sort, "Zustellung")
        );
    }

    private ArticleTourOption findArticleTour(Integer sort) {
        for (ArticleTourOption option : articleTourOptions()) {
            if (option.sort().equals(sort)) {
                return option;
            }
        }
        return articleTourOptions().get(0);
    }

    private String tourLabel(Integer sort) {
        if (sort == null) {
            return "";
        }
        if (sort.equals(pickupEditor.sort)) {
            return "Abholung";
        }
        if (sort.equals(deliveryEditor.sort)) {
            return "Zustellung";
        }
        return "Sort " + sort;
    }

    private String persistedTourSortLabel(Integer sort) {
        if (sort != null && sort == 0) {
            return "Global";
        }
        return tourLabel(sort);
    }

    private JobPriceTypeOption jobPriceTypeFor(Integer sort) {
        if (sort == null) {
            return null;
        }
        JobPriceTypeOption option = jobPriceTypeBySort.get(sort);
        if (option != null) {
            return option;
        }
        return new JobPriceTypeOption(sort, "", categoryLabel(sort));
    }

    private String categoryLabel(Integer sort) {
        JobPriceTypeOption option = jobPriceTypeBySort.get(sort);
        if (option != null && !option.categoryLabel().isBlank()) {
            return option.categoryLabel();
        }
        return sort != null ? "Kategorie " + sort : "";
    }

    private StreamResource attachmentResource(String fileName) {
        StreamResource resource = new StreamResource(fileName,
                () -> new ByteArrayInputStream(restClient.getBytes(attachmentContentPath(fileName))));
        resource.setContentType("application/octet-stream");
        return resource;
    }

    private StreamResource photoResource(String fileName) {
        StreamResource resource = new StreamResource(fileName,
                () -> new ByteArrayInputStream(restClient.getBytes(photoContentPath(fileName))));
        resource.setContentType(imageContentType(fileName));
        return resource;
    }

    private StreamResource acceptanceProtocolPdfResource() {
        StreamResource resource = new StreamResource("abnahmeprotokoll_" + jobId + ".pdf",
                () -> new ByteArrayInputStream(restClient.getBytes(acceptanceProtocolPdfPath())));
        resource.setContentType("application/pdf");
        return resource;
    }

    private StreamResource acceptanceProtocolSignatureResource() {
        StreamResource resource = new StreamResource("abnahmeprotokoll_" + jobId + "_signature.png",
                () -> new ByteArrayInputStream(restClient.getBytes(acceptanceProtocolSignaturePath())));
        resource.setContentType("image/png");
        return resource;
    }

    private String attachmentContentPath(String fileName) {
        return "/jobs/" + jobId + "/attachments/content?name=" + encode(fileName);
    }

    private String photoContentPath(String fileName) {
        return "/jobs/" + jobId + "/photos/content?name=" + encode(fileName);
    }

    private String acceptanceProtocolPdfPath() {
        return "/jobs/" + jobId + "/acceptance-protocol/pdf";
    }

    private String acceptanceProtocolSignaturePath() {
        return "/jobs/" + jobId + "/acceptance-protocol/signature";
    }

    private String encode(String value) {
        return URLEncoder.encode(value, StandardCharsets.UTF_8);
    }

    private String imageContentType(String fileName) {
        String lower = fileName != null ? fileName.toLowerCase() : "";
        if (lower.endsWith(".png")) {
            return "image/png";
        }
        if (lower.endsWith(".gif")) {
            return "image/gif";
        }
        return "image/jpeg";
    }

    private void resetState() {
        jobId = null;
        isNew = true;
        loadedCourierPrice = null;
        loadedLonghaulState = null;

        statusField.setValue(findStatus(0));
        orderTimeField.setValue(LocalDateTime.now());
        finishTimeField.clear();
        tourNameField.clear();
        courierIdField.clear();
        courierSidField.clear();
        vehicleTypeIdField.clear();
        payerCostCenterField.clear();
        relatedCostCenterField.clear();
        totalPriceField.clear();
        subtotalPriceField.clear();
        courierPriceField.clear();
        courierSubtotalField.clear();
        fixPriceField.clear();
        servicePriceField.clear();
        markupField.clear();
        selfServiceDiscountField.clear();
        manualLonghaulField.clear();
        invoiceTextField.clear();
        attachmentRemarkField.clear();
        servicesField.setValue(defaultJobServices());
        updateLonghaulStateInfo();
        acceptanceProtocolOverview.removeAll();
        acceptanceProtocolDetailTextField.clear();
        acceptanceProtocolGrid.setItems(List.of());
        acceptanceProtocolSignature.removeAll();
        acceptanceProtocolSignature.add(new Span("Keine Signatur vorhanden."));
        updateAcceptanceProtocolDownloadLinks(false);

        pickupEditor.clear(1);
        deliveryEditor.clear(2);

        articleRows.clear();
        jobPriceRows.clear();
        persistedTourServiceRows.clear();
        attachmentRows.clear();
        photoRows.clear();
        articleGrid.setItems(articleRows);
        jobPriceGrid.setItems(jobPriceRows);
        persistedTourServiceGrid.setItems(persistedTourServiceRows);
        attachmentGrid.setItems(attachmentRows);
        refreshPhotoGallery();
        refreshArticleActions();
        refreshJobPriceGrid();
        refreshPersistedTourServiceGrid();
    }

    private void applyJobValues(Map<String, Object> job) {
        statusField.setValue(findStatus(toInteger(job.get("status"), 0)));
        orderTimeField.setValue(parseDateTime(job.get("orderTime")));
        finishTimeField.setValue(parseDateTime(job.get("finishTime")));
        tourNameField.setValue(str(job.get("tourName")));
        courierIdField.setValue(str(job.get("courierId")));
        courierSidField.setValue(str(job.get("courierSid")));
        vehicleTypeIdField.setValue(str(job.get("vehicleTypeId")));
        payerCostCenterField.setValue(str(job.get("costCenterPayerId")));
        relatedCostCenterField.setValue(str(job.get("costCenterRelatedId")));
        totalPriceField.setValue(str(job.get("totalPrice")));
        subtotalPriceField.setValue(str(job.get("subtotalPrice")));
        courierPriceField.setValue(str(job.get("courierPrice")));
        courierSubtotalField.setValue(str(job.get("courierSubprice")));
        loadedCourierPrice = toBigDecimal(job.get("courierPrice"));
        loadedLonghaulState = toInteger(job.get("longhaul"), null);
        fixPriceField.setValue(str(job.get("fixPrice")));
        servicePriceField.setValue(str(job.get("servicePrice")));
        markupField.setValue(str(job.get("markup")));
        manualLonghaulField.setValue(Integer.valueOf(3).equals(loadedLonghaulState));
        servicesField.setValue(selectJobServices(toInteger(job.get("service"), null)));
        invoiceTextField.setValue(str(job.get("invoiceText")));
        updateLonghaulStateInfo();
    }

    private void ensureJobServiceOptionsLoaded() {
        if (!jobServiceOptions.isEmpty()) {
            servicesField.setItems(jobServiceOptions);
            if (servicesField.getValue().isEmpty()) {
                servicesField.setValue(defaultJobServices());
            }
            return;
        }

        try {
            List<LinkedHashMap<String, Object>> result = restClient.getList(
                    "/metatypes/type/service",
                    new ParameterizedTypeReference<>() {}
            );
            for (Map<String, Object> row : result) {
                Integer sort = toInteger(row.get("sort"), null);
                if (sort == null) {
                    continue;
                }
                String label = str(row.get("value"));
                if (label.isBlank()) {
                    label = str(row.get("mappedValue"));
                }
                jobServiceOptions.add(new JobServiceOption(sort, label.isBlank() ? "Service " + sort : label));
            }
            jobServiceOptions.sort(Comparator.comparing(JobServiceOption::sort, Comparator.nullsLast(Integer::compareTo)));
            servicesField.setItems(jobServiceOptions);
            if (servicesField.getValue().isEmpty()) {
                servicesField.setValue(defaultJobServices());
            }
        } catch (Exception e) {
            showError("Leistungen konnten nicht geladen werden: " + e.getMessage());
        }
    }

    private Map<String, Object> buildPricingRequest() {
        Map<String, Object> request = new HashMap<>();
        request.put("job", buildJobPayload());
        request.put("prices", buildManagedJobPriceRows().stream().map(this::toJobPricePayload).toList());
        request.put("tours", List.of(pickupEditor.toRequest(), deliveryEditor.toRequest()));
        request.put("manualCourierTotalOverride", hasManualCourierTotalOverride());
        return request;
    }

    private List<JobPriceRow> buildManagedJobPriceRows() {
        List<JobPriceRow> rows = new ArrayList<>(jobPriceRows);
        upsertManagedJobPriceRow(
                rows,
                JB_MARKUP_MAN,
                toBigDecimal(markupField.getValue()),
                existingJobPriceRemark(JB_MARKUP_MAN),
                existingJobPriceReference(JB_MARKUP_MAN)
        );
        upsertManagedJobPriceRow(
                rows,
                JB_CR_SUBTOTAL,
                toBigDecimal(courierSubtotalField.getValue()),
                existingJobPriceRemark(JB_CR_SUBTOTAL),
                existingJobPriceReference(JB_CR_SUBTOTAL)
        );
        upsertManagedJobPriceRow(
                rows,
                JB_SELF_SERVICE_DISCOUNT,
                toBigDecimal(selfServiceDiscountField.getValue()),
                existingJobPriceRemark(JB_SELF_SERVICE_DISCOUNT),
                existingJobPriceReference(JB_SELF_SERVICE_DISCOUNT)
        );
        rows.sort(Comparator
                .comparing(JobPriceRow::metaSort, Comparator.nullsLast(Integer::compareTo))
                .thenComparing(JobPriceRow::remark, Comparator.nullsLast(String.CASE_INSENSITIVE_ORDER)));
        return rows;
    }

    private void upsertManagedJobPriceRow(List<JobPriceRow> rows,
                                          Integer metaSort,
                                          BigDecimal price,
                                          String remark,
                                          Long reference) {
        JobPriceTypeOption option = jobPriceTypeFor(metaSort);
        JobPriceRow newRow = hasAmount(price)
                ? new JobPriceRow(
                        metaSort,
                        option != null ? option.internalName() : "",
                        option != null ? option.categoryLabel() : categoryLabel(metaSort),
                        price,
                        remark,
                        reference
                )
                : null;

        for (int index = 0; index < rows.size(); index++) {
            JobPriceRow row = rows.get(index);
            if (Objects.equals(metaSort, row.metaSort())) {
                if (newRow == null) {
                    rows.remove(index);
                } else {
                    rows.set(index, newRow);
                }
                return;
            }
        }
        if (newRow != null) {
            rows.add(newRow);
        }
    }

    private String existingJobPriceRemark(Integer metaSort) {
        return jobPriceRows.stream()
                .filter(row -> Objects.equals(metaSort, row.metaSort()))
                .map(JobPriceRow::remark)
                .findFirst()
                .orElse("");
    }

    private Long existingJobPriceReference(Integer metaSort) {
        return jobPriceRows.stream()
                .filter(row -> Objects.equals(metaSort, row.metaSort()))
                .map(JobPriceRow::reference)
                .findFirst()
                .orElse(null);
    }

    private boolean hasAmount(BigDecimal value) {
        return value != null && value.compareTo(BigDecimal.ZERO) != 0;
    }

    private boolean hasManualCourierTotalOverride() {
        BigDecimal currentCourierPrice = toBigDecimal(courierPriceField.getValue());
        if (!hasAmount(currentCourierPrice)) {
            return false;
        }
        if (!courierSubtotalField.getValue().isBlank()) {
            return false;
        }
        return !sameAmount(currentCourierPrice, loadedCourierPrice);
    }

    private Integer buildManualLonghaulPayload() {
        if (!sessionData.canManageLonghaul()) {
            return null;
        }
        if (manualLonghaulField.getValue()) {
            return 3;
        }
        if (Integer.valueOf(3).equals(loadedLonghaulState)) {
            return 0;
        }
        return null;
    }

    private void updateLonghaulStateInfo() {
        Integer previewState = previewLonghaulState();
        StringBuilder text = new StringBuilder("Ferntour-Status: ")
                .append(longhaulStateLabel(loadedLonghaulState));
        if (!Objects.equals(previewState, loadedLonghaulState)) {
            text.append(" -> ").append(longhaulStateLabel(previewState)).append(" nach Speichern");
        } else if (!sessionData.canManageLonghaul()) {
            text.append(" (nur mit Longhaul-Recht aenderbar)");
        } else if (!isNew && Integer.valueOf(3).equals(loadedLonghaulState)) {
            text.append(" (deaktivieren und speichern entfernt die manuelle FT)");
        } else if (isNew && sessionData.canManageLonghaul()) {
            text.append(" (optional beim ersten Speichern als manuelle FT setzen)");
        }
        longhaulStateInfo.setText(text.toString());
    }

    private Integer previewLonghaulState() {
        if (Boolean.TRUE.equals(manualLonghaulField.getValue())) {
            return 3;
        }
        if (Integer.valueOf(3).equals(loadedLonghaulState)) {
            return 0;
        }
        return loadedLonghaulState;
    }

    private String longhaulStateLabel(Integer state) {
        if (Integer.valueOf(1).equals(state)) {
            return "Automatische FT";
        }
        if (Integer.valueOf(2).equals(state)) {
            return "Rueckgabe an HQ";
        }
        if (Integer.valueOf(3).equals(state)) {
            return "Manuelle FT";
        }
        return "Kein FT-Status";
    }

    private boolean sameAmount(BigDecimal left, BigDecimal right) {
        if (left == null && right == null) {
            return true;
        }
        if (left == null || right == null) {
            return false;
        }
        return left.compareTo(right) == 0;
    }

    private Map<String, Object> castMap(Map<?, ?> rawMap) {
        Map<String, Object> casted = new LinkedHashMap<>();
        for (Map.Entry<?, ?> entry : rawMap.entrySet()) {
            casted.put(String.valueOf(entry.getKey()), entry.getValue());
        }
        return casted;
    }

    private List<Map<String, Object>> toMapList(Object[] rows) {
        List<Map<String, Object>> items = new ArrayList<>();
        if (rows == null) {
            return items;
        }
        for (Object row : rows) {
            if (row instanceof Map<?, ?> rawMap) {
                Map<String, Object> casted = new LinkedHashMap<>();
                for (Map.Entry<?, ?> entry : rawMap.entrySet()) {
                    casted.put(String.valueOf(entry.getKey()), entry.getValue());
                }
                items.add(casted);
            }
        }
        return items;
    }

    private JobStatusOption findStatus(Integer id) {
        for (JobStatusOption option : STATUS_OPTIONS) {
            if (option.id() == id) {
                return option;
            }
        }
        return STATUS_OPTIONS.get(0);
    }

    private LocalDateTime parseDateTime(Object value) {
        if (value == null || value.toString().isBlank()) {
            return null;
        }
        return LocalDateTime.parse(value.toString());
    }

    private Long toLong(Object value) {
        if (value == null) {
            return null;
        }
        String raw = value.toString().trim();
        if (raw.isBlank()) {
            return null;
        }
        if (value instanceof Number number) {
            return number.longValue();
        }
        return Long.parseLong(raw);
    }

    private Integer toInteger(Object value, Integer fallback) {
        if (value == null) {
            return fallback;
        }
        String raw = value.toString().trim();
        if (raw.isBlank()) {
            return fallback;
        }
        if (value instanceof Number number) {
            return number.intValue();
        }
        return Integer.parseInt(raw);
    }

    private BigDecimal toBigDecimal(Object value) {
        if (value == null) {
            return null;
        }
        String raw = value.toString().trim();
        if (raw.isBlank()) {
            return null;
        }
        if (value instanceof BigDecimal bigDecimal) {
            return bigDecimal;
        }
        return new BigDecimal(raw.replace(',', '.'));
    }

    private String str(Object value) {
        return value != null ? value.toString() : "";
    }

    private String nullableLong(Long value) {
        return value != null ? String.valueOf(value) : "";
    }

    private String nullableInteger(Integer value) {
        return value != null ? String.valueOf(value) : "";
    }

    private String nullableBigDecimal(BigDecimal value) {
        return value != null ? value.stripTrailingZeros().toPlainString() : "";
    }

    private Set<JobServiceOption> defaultJobServices() {
        LinkedHashSet<JobServiceOption> defaults = new LinkedHashSet<>();
        for (JobServiceOption option : jobServiceOptions) {
            if (Objects.equals(option.sort(), 0)) {
                defaults.add(option);
                break;
            }
        }
        return defaults;
    }

    private Set<JobServiceOption> selectJobServices(Integer serviceMask) {
        ensureJobServiceOptionsLoaded();
        int effectiveMask = serviceMask == null || serviceMask <= 0 ? 1 : serviceMask;
        LinkedHashSet<JobServiceOption> selected = new LinkedHashSet<>();
        for (JobServiceOption option : jobServiceOptions) {
            Integer sort = option.sort();
            if (sort != null && sort >= 0 && (effectiveMask & (1 << sort)) != 0) {
                selected.add(option);
            }
        }
        if (selected.isEmpty()) {
            selected.addAll(defaultJobServices());
        }
        return selected;
    }

    private Integer encodeJobServices(Set<JobServiceOption> selection) {
        int mask = 0;
        if (selection == null) {
            return mask;
        }
        for (JobServiceOption option : selection) {
            if (option != null && option.sort() != null && option.sort() >= 0) {
                mask |= (1 << option.sort());
            }
        }
        return mask;
    }

    private void updateAcceptanceProtocolDownloadLinks(boolean enabled) {
        boolean visible = enabled && jobId != null;
        acceptanceProtocolPdfLink.setVisible(visible);
        mediaAcceptanceProtocolPdfLink.setVisible(visible);
        if (visible) {
            acceptanceProtocolPdfLink.setHref(acceptanceProtocolPdfResource());
            mediaAcceptanceProtocolPdfLink.setHref(acceptanceProtocolPdfResource());
        }
    }

    private String joinLabels(Object raw) {
        if (raw instanceof List<?> list) {
            List<String> labels = list.stream()
                    .map(item -> item != null ? item.toString() : "")
                    .filter(label -> !label.isBlank())
                    .toList();
            return labels.isEmpty() ? "-" : String.join(", ", labels);
        }
        return "-";
    }

    private String formatBytes(Long sizeBytes) {
        if (sizeBytes == null) {
            return "";
        }
        if (sizeBytes < 1024) {
            return sizeBytes + " B";
        }
        if (sizeBytes < 1024 * 1024) {
            return (sizeBytes / 1024) + " KB";
        }
        return String.format("%.1f MB", sizeBytes / 1024.0 / 1024.0);
    }

    private void showError(String message) {
        Notification notification = Notification.show(message, 5000, Notification.Position.MIDDLE);
        notification.addThemeVariants(NotificationVariant.LUMO_ERROR);
    }

    private static final class TourEditor {
        private final String caption;
        private Long id;
        private Integer sort;
        private final TextField companyField = new TextField("Firma");
        private final TextField personField = new TextField("Person");
        private final TextField streetField = new TextField("Strasse");
        private final TextField hsnoField = new TextField("Hausnummer");
        private final TextField zipcodeField = new TextField("PLZ");
        private final TextField cityField = new TextField("Ort");
        private final TextField commissionField = new TextField("Kommission");
        private final TextField phoneField = new TextField("Telefon");
        private final TextField statusField = new TextField("Status");
        private final TextField costCenterIdField = new TextField("Kostenstelle-ID");
        private final TextField remarkField = new TextField("Bemerkung");

        private TourEditor(String caption, int defaultSort) {
            this.caption = caption;
            clear(defaultSort);
        }

        private void clear(int defaultSort) {
            id = null;
            sort = defaultSort;
            companyField.clear();
            personField.clear();
            streetField.clear();
            hsnoField.clear();
            zipcodeField.clear();
            cityField.clear();
            commissionField.clear();
            phoneField.clear();
            statusField.clear();
            costCenterIdField.clear();
            remarkField.clear();
        }

        private void load(Map<String, Object> row, int fallbackSort) {
            id = row.get("id") instanceof Number number ? number.longValue() : null;
            sort = row.get("sort") instanceof Number number ? number.intValue() : fallbackSort;
            companyField.setValue(row.get("comp") != null ? row.get("comp").toString() : "");
            personField.setValue(row.get("person") != null ? row.get("person").toString() : "");
            streetField.setValue(row.get("street") != null ? row.get("street").toString() : "");
            hsnoField.setValue(row.get("hsno") != null ? row.get("hsno").toString() : "");
            zipcodeField.setValue(row.get("zipcode") != null ? row.get("zipcode").toString() : "");
            cityField.setValue(row.get("city") != null ? row.get("city").toString() : "");
            commissionField.setValue(row.get("commissionNo") != null ? row.get("commissionNo").toString() : "");
            phoneField.setValue(row.get("phone") != null ? row.get("phone").toString() : "");
            statusField.setValue(row.get("status") != null ? row.get("status").toString() : "");
            costCenterIdField.setValue(row.get("costCenterId") != null ? row.get("costCenterId").toString() : "");
            remarkField.setValue(row.get("remark") != null ? row.get("remark").toString() : "");
        }

        private Map<String, Object> toRequest() {
            Map<String, Object> tour = new HashMap<>();
            tour.put("id", id);
            tour.put("sort", sort);
            tour.put("comp", companyField.getValue());
            tour.put("person", personField.getValue());
            tour.put("street", streetField.getValue());
            tour.put("hsno", hsnoField.getValue());
            tour.put("zipcode", zipcodeField.getValue());
            tour.put("city", cityField.getValue());
            tour.put("commissionNo", commissionField.getValue());
            tour.put("phone", phoneField.getValue());
            tour.put("status", parseInteger(statusField.getValue()));
            tour.put("costCenterId", parseLong(costCenterIdField.getValue()));
            tour.put("remark", remarkField.getValue());
            return tour;
        }

        private Integer parseInteger(String value) {
            if (value == null || value.isBlank()) {
                return null;
            }
            return Integer.parseInt(value.trim());
        }

        private Long parseLong(String value) {
            if (value == null || value.isBlank()) {
                return null;
            }
            return Long.parseLong(value.trim());
        }
    }

    private record JobStatusOption(int id, String label) { }

    private record ArticleTourOption(Integer sort, String label) { }

    private record ArticleRow(Long id,
                              Long articleTypeId,
                              Integer quantity,
                              Integer packingPieces,
                              BigDecimal weight,
                              BigDecimal price,
                              Integer tourSort,
                              String tourLabel) { }

    private record JobPriceTypeOption(Integer sort, String internalName, String categoryLabel) {
        private String label() {
            if (internalName == null || internalName.isBlank() || internalName.equals(categoryLabel)) {
                return categoryLabel;
            }
            return categoryLabel + " (" + internalName + ")";
        }
    }

    private record JobServiceOption(Integer sort, String label) { }

    private record JobPriceRow(Integer metaSort,
                               String internalName,
                               String categoryLabel,
                               BigDecimal price,
                               String remark,
                               Long reference) { }

    private record PersistedTourServiceRow(Long costCenterId,
                                           String costCenterName,
                                           Integer tourSort,
                                           String tourLabel,
                                           String serviceName,
                                           String serviceTypeName,
                                           BigDecimal price,
                                           BigDecimal discount,
                                           BigDecimal businessVolume) {

        private String costCenterLabel() {
            if (costCenterId == null && (costCenterName == null || costCenterName.isBlank())) {
                return "";
            }
            if (costCenterName == null || costCenterName.isBlank()) {
                return String.valueOf(costCenterId);
            }
            return costCenterId != null ? costCenterName + " (" + costCenterId + ")" : costCenterName;
        }
    }

    private record AttachmentRow(Long id,
                                 String fileName,
                                 String remark,
                                 String source,
                                 String modifiedAt,
                                 Long sizeBytes,
                                 boolean downloadable,
                                 boolean deletable) { }

    private record AcceptanceProtocolQuestionRow(Integer sort,
                                                 String question,
                                                 String answer,
                                                 String article,
                                                 String remark,
                                                 String mailState) { }

    private record PhotoRow(String fileName, String source, boolean downloadable) { }
}
