From a9bf8ecdd18d0daa9335b9cc60ab0111eac1d5f5 Mon Sep 17 00:00:00 2001 From: Dennis Nemec Date: Mon, 1 Jun 2026 17:12:28 +0200 Subject: [PATCH] Final commit. --- docs/BACKEND_MIGRATION.md | 35 + lib/bloc/app_bloc.dart | 49 +- lib/bloc/app_states.dart | 39 +- lib/data/cache/attachment_cache.dart | 162 ++ lib/data/mapper/tour_mapper.dart | 408 ++++ lib/data/network/backend_config.dart | 40 + .../network/keycloak_oidc_token_provider.dart | 32 +- lib/data/network/network_locator.dart | 2 +- .../payment_methods_repository_impl.dart | 125 + lib/data/repository/tour_repository_impl.dart | 569 +++++ lib/domain/entity/address.dart | 55 + lib/domain/entity/article.dart | 45 + lib/domain/entity/contact_source.dart | 226 ++ lib/domain/entity/customer.dart | 53 + lib/domain/entity/delivery.dart | 152 ++ lib/domain/entity/delivery_credit.dart | 20 + lib/domain/entity/delivery_item.dart | 106 + lib/domain/entity/delivery_note.dart | 83 + lib/domain/entity/delivery_service_value.dart | 15 + lib/domain/entity/payment_method.dart | 38 + lib/domain/entity/scan_intent.dart | 93 + lib/domain/entity/scan_progress.dart | 48 + lib/domain/entity/service.dart | 29 + lib/domain/entity/tour.dart | 53 + lib/domain/entity/tour_details.dart | 428 ++++ lib/domain/entity/warehouse.dart | 32 + .../payment_methods_repository.dart | 36 + lib/domain/repository/tour_repository.dart | 210 ++ lib/dto/address.dart | 20 - lib/dto/address.g.dart | 20 - lib/dto/article.dart | 45 - lib/dto/article.g.dart | 45 - lib/dto/basic_response.dart | 16 - lib/dto/basic_response.g.dart | 19 - lib/dto/car.dart | 16 - lib/dto/car.g.dart | 15 - lib/dto/car_add.dart | 20 - lib/dto/car_add.g.dart | 17 - lib/dto/car_add_response.dart | 19 - lib/dto/car_add_response.g.dart | 21 - lib/dto/car_get_response.dart | 19 - lib/dto/car_get_response.g.dart | 24 - lib/dto/component.dart | 23 - lib/dto/component.g.dart | 22 - lib/dto/contact_person.dart | 20 - lib/dto/contact_person.g.dart | 23 - lib/dto/customer.dart | 16 - lib/dto/customer.g.dart | 20 - lib/dto/delivery.dart | 84 - lib/dto/delivery.g.dart | 89 - lib/dto/delivery_response.dart | 22 - lib/dto/delivery_response.g.dart | 25 - lib/dto/delivery_update.dart | 85 - lib/dto/delivery_update.g.dart | 49 - lib/dto/delivery_update_response.dart | 17 - lib/dto/delivery_update_response.g.dart | 18 - lib/dto/discount.dart | 15 - lib/dto/discount.g.dart | 20 - lib/dto/discount_add.dart | 18 - lib/dto/discount_add.g.dart | 21 - lib/dto/discount_add_response.dart | 64 - lib/dto/discount_add_response.g.dart | 61 - lib/dto/discount_remove.dart | 15 - lib/dto/discount_remove.g.dart | 13 - lib/dto/discount_remove_response.dart | 23 - lib/dto/discount_remove_response.g.dart | 23 - lib/dto/discount_update.dart | 18 - lib/dto/discount_update.g.dart | 21 - lib/dto/discount_update_response.dart | 22 - lib/dto/discount_update_response.g.dart | 26 - lib/dto/driver.dart | 16 - lib/dto/driver.g.dart | 24 - lib/dto/image.dart | 18 - lib/dto/image.g.dart | 19 - lib/dto/image_note_response.dart | 17 - lib/dto/image_note_response.g.dart | 20 - lib/dto/note.dart | 16 - lib/dto/note.g.dart | 15 - lib/dto/note_add_response.dart | 17 - lib/dto/note_add_response.g.dart | 24 - lib/dto/note_get_response.dart | 22 - lib/dto/note_get_response.g.dart | 29 - lib/dto/note_template.dart | 18 - lib/dto/note_template.g.dart | 21 - lib/dto/note_template_response.dart | 19 - lib/dto/note_template_response.g.dart | 26 - lib/dto/payment.dart | 17 - lib/dto/payment.g.dart | 21 - lib/dto/payments.dart | 14 - lib/dto/payments.g.dart | 20 - lib/dto/scan.dart | 15 - lib/dto/scan.g.dart | 14 - lib/dto/scan_response.dart | 18 - lib/dto/scan_response.g.dart | 21 - lib/dto/set_article_amount_request.dart | 23 - lib/dto/set_article_amount_request.g.dart | 25 - lib/dto/set_article_amount_response.dart | 21 - lib/dto/set_article_amount_response.g.dart | 23 - lib/exceptions.dart | 1 - .../authentication/bloc/auth_bloc.dart | 11 +- lib/feature/car_selection/bloc/bloc.dart | 2 +- lib/feature/car_selection/bloc/events.dart | 2 +- lib/feature/car_selection/bloc/state.dart | 2 +- .../presentation/car_selection_card.dart | 2 +- .../presentation/car_selection_page.dart | 2 +- lib/feature/cars/presentation/car_card.dart | 2 +- lib/feature/delivery/bloc/phase_bloc.dart | 37 +- lib/feature/delivery/bloc/tour_bloc.dart | 1818 +++++++++----- lib/feature/delivery/bloc/tour_event.dart | 524 ++-- lib/feature/delivery/bloc/tour_state.dart | 130 +- .../delivery/detail/bloc/note_bloc.dart | 172 -- .../delivery/detail/bloc/note_event.dart | 75 - .../delivery/detail/bloc/note_state.dart | 41 - .../delivery/detail/bloc/workflow_bloc.dart | 46 + .../delivery/detail/bloc/workflow_event.dart | 43 + .../delivery/detail/bloc/workflow_state.dart | 87 + lib/feature/delivery/detail/exceptions.dart | 1 - lib/feature/delivery/detail/model/note.dart | 10 - .../presentation/article/article_list.dart | 34 - .../article/article_list_item.dart | 97 - .../article/article_reset_scan_dialog.dart | 119 - .../article/article_unscan_dialog.dart | 179 -- .../presentation/delivery_detail_page.dart | 593 +++-- .../presentation/delivery_discount.dart | 221 -- .../detail/presentation/delivery_options.dart | 121 - .../detail/presentation/delivery_sign.dart | 702 +++--- .../detail/presentation/delivery_summary.dart | 177 -- .../presentation/note/note_add_dialog.dart | 149 -- .../presentation/note/note_edit_dialog.dart | 111 - .../presentation/note/note_fail_page.dart | 43 - .../note/note_image_overview.dart | 108 - .../detail/presentation/note/note_list.dart | 30 - .../presentation/note/note_list_item.dart | 105 - .../presentation/note/note_overview.dart | 181 -- .../detail/presentation/steps/step.dart | 32 - .../steps/step_article_management.dart | 74 - .../presentation/steps/step_articles.dart | 395 +++ .../steps/step_delivery_options.dart | 25 - .../detail/presentation/steps/step_info.dart | 1270 +++++++--- .../detail/presentation/steps/step_note.dart | 86 - .../detail/presentation/steps/step_notes.dart | 716 ++++++ .../presentation/steps/step_services.dart | 324 +++ .../presentation/steps/step_summary.dart | 517 +++- .../presentation/widget/discount_editor.dart | 355 +++ .../repository/delivery_repository.dart | 57 - .../detail/repository/note_repository.dart | 96 - .../detail/service/notes_service.dart | 327 --- .../overview/model/sorting_information.dart | 56 - .../presentation/delivery_fail_page.dart | 23 +- .../overview/presentation/delivery_info.dart | 39 +- .../overview/presentation/delivery_item.dart | 153 -- .../overview/presentation/delivery_list.dart | 126 - .../presentation/delivery_overview.dart | 1092 ++++++++- .../delivery_overview_custom_sort.dart | 151 -- .../presentation/delivery_overview_page.dart | 135 +- .../presentation/delivery_selection_page.dart | 290 +-- .../presentation/delivery_sort_page.dart | 258 +- .../overview/service/distance_service.dart | 82 - .../overview/service/phase_service.dart | 71 +- .../overview/service/reorder_service.dart | 74 - .../widget/sortable_delivery_list.dart | 193 +- .../filiale_pickup_scan_page.dart | 457 ++++ .../repository/process_repository.dart | 95 - .../delivery/repository/tour_repository.dart | 441 ---- .../delivery/service/tour_service.dart | 448 ---- lib/feature/delivery/util.dart | 22 - lib/feature/feature_flags.dart | 46 + lib/feature/loading/model/loading_group.dart | 115 - .../presentation/loading_customer_page.dart | 2165 +++++++++-------- .../presentation/loading_overview_page.dart | 742 ++++-- lib/feature/loading/util/loading_order.dart | 68 - lib/feature/loading/widget/article_row.dart | 535 ---- .../loading/widget/hold_selection_dialog.dart | 243 -- .../loading/widget/reason_catalog.dart | 46 + .../loading/widget/reason_picker_dialog.dart | 140 -- .../loading/widget/reason_picker_sheet.dart | 225 ++ .../bloc/payment_methods_cubit.dart | 74 + lib/feature/scan/model/article.dart | 21 - lib/feature/scan/presentation/scanner.dart | 77 - .../scan/repository/scan_repository.dart | 11 - lib/feature/scan/service/scan_service.dart | 41 - lib/feature/scan/util.dart | 35 - lib/main.dart | 6 + lib/model/address.dart | 24 - lib/model/article.dart | 106 - lib/model/car.dart | 6 - lib/model/component.dart | 34 - lib/model/customer.dart | 19 - lib/model/delivery.dart | 398 --- lib/model/tour.dart | 133 - lib/model/user.dart | 5 - lib/persistence.dart | 20 - lib/repository.dart | 11 - lib/repository/config.dart | 21 - lib/repository/file.dart | 38 - lib/repository/user_repository.dart | 3 - lib/services/erpframe.dart | 23 - lib/util.dart | 86 - lib/widget/app.dart | 56 +- lib/widget/attachment_image.dart | 184 ++ lib/widget/home/presentation/home.dart | 178 +- .../operations/bloc/operation_bloc.dart | 17 +- .../presentation/operation_view_enforcer.dart | 34 +- lib/widget/phase_stepper/phase_stepper.dart | 110 +- .../scanner/article_scanner_stripe.dart | 201 ++ lib/widget/scanner/item_matcher.dart | 99 + lib/widget/scanner/manual_entry_dialog.dart | 74 + lib/widget/scanner/scan_code_parser.dart | 28 + lib/widget/warehouse_badge.dart | 6 +- openapi/holzleitner.json | 1906 ++++++++++++++- .../holzleitner_api/.openapi-generator/FILES | 126 +- packages/holzleitner_api/README.md | 55 + packages/holzleitner_api/doc/AdminApi.md | 196 ++ .../holzleitner_api/doc/AttachmentsApi.md | 64 + .../doc/CompleteDeliveryAcknowledgements.md | 20 + .../holzleitner_api/doc/ContactChannel.md | 19 + packages/holzleitner_api/doc/ContactKind.md | 14 + packages/holzleitner_api/doc/ContactRole.md | 14 + packages/holzleitner_api/doc/ContactSource.md | 24 + .../doc/CreateDeliveryNoteRequest.md | 2 + .../doc/CreatePaymentMethodRequest.md | 16 + .../doc/CreateServiceRequest.md | 20 + packages/holzleitner_api/doc/CreditAction.md | 14 + .../doc/DeliveredBelegnummernResponse.md | 17 + packages/holzleitner_api/doc/DeliveriesApi.md | 268 +- packages/holzleitner_api/doc/Delivery.md | 2 + .../holzleitner_api/doc/DeliveryCredit.md | 17 + .../doc/DeliveryCreditEventRequest.md | 19 + .../doc/DeliveryCreditResponse.md | 15 + packages/holzleitner_api/doc/DeliveryItem.md | 2 + packages/holzleitner_api/doc/DeliveryNote.md | 3 + .../doc/DeliveryServiceResponse.md | 15 + .../doc/DeliveryServiceValue.md | 18 + .../holzleitner_api/doc/DeliveryWithItems.md | 2 + packages/holzleitner_api/doc/ImportSummary.md | 21 + .../doc/MarkMailSentRequest.md | 15 + .../doc/MarkMailSentResponse.md | 15 + packages/holzleitner_api/doc/PaymentMethod.md | 19 + .../doc/PaymentMethodResponse.md | 15 + .../holzleitner_api/doc/PaymentMethodsApi.md | 182 ++ .../holzleitner_api/doc/PaymentMethodsList.md | 15 + packages/holzleitner_api/doc/ScanEvent.md | 2 + packages/holzleitner_api/doc/ScanState.md | 1 + packages/holzleitner_api/doc/Service.md | 22 + packages/holzleitner_api/doc/ServiceKind.md | 14 + .../holzleitner_api/doc/ServiceResponse.md | 15 + packages/holzleitner_api/doc/ServicesApi.md | 182 ++ packages/holzleitner_api/doc/ServicesList.md | 15 + .../doc/SetDeliveryServiceRequest.md | 17 + .../holzleitner_api/doc/SyncContactChannel.md | 17 + .../holzleitner_api/doc/SyncContactSource.md | 23 + packages/holzleitner_api/doc/SyncDelivery.md | 5 + .../holzleitner_api/doc/SyncDeliveryItem.md | 4 +- packages/holzleitner_api/doc/TourDetails.md | 5 + .../doc/UpdateDeliveryNoteRequest.md | 16 + .../doc/UpdatePaymentMethodRequest.md | 16 + .../doc/UpdateServiceRequest.md | 19 + .../holzleitner_api/lib/holzleitner_api.dart | 34 + packages/holzleitner_api/lib/src/api.dart | 28 + .../lib/src/api/admin_api.dart | 360 +++ .../lib/src/api/attachments_api.dart | 93 + .../lib/src/api/deliveries_api.dart | 513 +++- .../lib/src/api/payment_methods_api.dart | 368 +++ .../lib/src/api/services_api.dart | 368 +++ .../lib/src/model/audit_action.dart | 13 +- .../lib/src/model/audit_action.g.dart | 7 + .../complete_delivery_acknowledgements.dart | 205 ++ .../complete_delivery_acknowledgements.g.dart | 181 ++ .../lib/src/model/contact_channel.dart | 172 ++ .../lib/src/model/contact_channel.g.dart | 146 ++ .../lib/src/model/contact_kind.dart | 42 + .../lib/src/model/contact_kind.g.dart | 85 + .../lib/src/model/contact_role.dart | 45 + .../lib/src/model/contact_role.g.dart | 92 + .../lib/src/model/contact_source.dart | 273 +++ .../lib/src/model/contact_source.g.dart | 203 ++ .../model/create_delivery_note_request.dart | 39 + .../model/create_delivery_note_request.g.dart | 30 +- .../model/create_payment_method_request.dart | 124 + .../create_payment_method_request.g.dart | 109 + .../lib/src/model/create_service_request.dart | 199 ++ .../src/model/create_service_request.g.dart | 159 ++ .../lib/src/model/credit_action.dart | 36 + .../lib/src/model/credit_action.g.dart | 71 + .../delivered_belegnummern_response.dart | 142 ++ .../delivered_belegnummern_response.g.dart | 137 ++ .../lib/src/model/delivery.dart | 34 + .../lib/src/model/delivery.g.dart | 34 + .../lib/src/model/delivery_credit.dart | 139 ++ .../lib/src/model/delivery_credit.g.dart | 120 + .../model/delivery_credit_event_request.dart | 185 ++ .../delivery_credit_event_request.g.dart | 148 ++ .../src/model/delivery_credit_response.dart | 111 + .../src/model/delivery_credit_response.g.dart | 107 + .../lib/src/model/delivery_item.dart | 37 + .../lib/src/model/delivery_item.g.dart | 26 + .../lib/src/model/delivery_note.dart | 56 + .../lib/src/model/delivery_note.g.dart | 40 + .../src/model/delivery_service_response.dart | 107 + .../model/delivery_service_response.g.dart | 108 + .../lib/src/model/delivery_service_value.dart | 160 ++ .../src/model/delivery_service_value.g.dart | 134 + .../lib/src/model/delivery_with_items.dart | 26 + .../lib/src/model/delivery_with_items.g.dart | 28 + .../lib/src/model/import_summary.dart | 211 ++ .../lib/src/model/import_summary.g.dart | 187 ++ .../lib/src/model/mark_mail_sent_request.dart | 108 + .../src/model/mark_mail_sent_request.g.dart | 108 + .../src/model/mark_mail_sent_response.dart | 107 + .../src/model/mark_mail_sent_response.g.dart | 94 + .../lib/src/model/payment_method.dart | 172 ++ .../lib/src/model/payment_method.g.dart | 145 ++ .../src/model/payment_method_response.dart | 107 + .../src/model/payment_method_response.g.dart | 106 + .../lib/src/model/payment_methods_list.dart | 108 + .../lib/src/model/payment_methods_list.g.dart | 107 + .../lib/src/model/scan_event.dart | 41 +- .../lib/src/model/scan_event.g.dart | 24 + .../lib/src/model/scan_state.dart | 17 + .../lib/src/model/scan_state.g.dart | 16 +- .../lib/src/model/service.dart | 226 ++ .../lib/src/model/service.g.dart | 178 ++ .../lib/src/model/service_kind.dart | 36 + .../lib/src/model/service_kind.g.dart | 71 + .../lib/src/model/service_response.dart | 107 + .../lib/src/model/service_response.g.dart | 103 + .../lib/src/model/services_list.dart | 108 + .../lib/src/model/services_list.g.dart | 104 + .../model/set_delivery_service_request.dart | 147 ++ .../model/set_delivery_service_request.g.dart | 119 + .../lib/src/model/sync_contact_channel.dart | 141 ++ .../lib/src/model/sync_contact_channel.g.dart | 121 + .../lib/src/model/sync_contact_source.dart | 261 ++ .../lib/src/model/sync_contact_source.g.dart | 207 ++ .../lib/src/model/sync_delivery.dart | 99 + .../lib/src/model/sync_delivery.g.dart | 68 +- .../lib/src/model/sync_delivery_item.dart | 43 +- .../lib/src/model/sync_delivery_item.g.dart | 25 + .../lib/src/model/tour_details.dart | 96 +- .../lib/src/model/tour_details.g.dart | 79 + .../model/update_delivery_note_request.dart | 129 + .../model/update_delivery_note_request.g.dart | 107 + .../model/update_payment_method_request.dart | 130 + .../update_payment_method_request.g.dart | 106 + .../lib/src/model/update_service_request.dart | 185 ++ .../src/model/update_service_request.g.dart | 140 ++ .../holzleitner_api/lib/src/serializers.dart | 60 + .../lib/src/serializers.g.dart | 77 +- .../holzleitner_api/test/admin_api_test.dart | 25 + .../test/attachments_api_test.dart | 18 + ...mplete_delivery_acknowledgements_test.dart | 35 + .../test/contact_channel_test.dart | 36 + .../test/contact_kind_test.dart | 9 + .../test/contact_role_test.dart | 9 + .../test/contact_source_test.dart | 61 + .../create_payment_method_request_test.dart | 23 + .../test/create_service_request_test.dart | 43 + .../test/credit_action_test.dart | 9 + .../delivered_belegnummern_response_test.dart | 29 + .../delivery_credit_event_request_test.dart | 40 + .../test/delivery_credit_response_test.dart | 17 + .../test/delivery_credit_test.dart | 27 + .../test/delivery_service_response_test.dart | 16 + .../test/delivery_service_value_test.dart | 31 + .../test/import_summary_test.dart | 37 + .../test/mark_mail_sent_request_test.dart | 17 + .../test/mark_mail_sent_response_test.dart | 17 + .../test/payment_method_response_test.dart | 16 + .../test/payment_method_test.dart | 38 + .../test/payment_methods_api_test.dart | 39 + .../test/payment_methods_list_test.dart | 16 + .../test/service_kind_test.dart | 9 + .../test/service_response_test.dart | 16 + .../holzleitner_api/test/service_test.dart | 51 + .../test/services_api_test.dart | 39 + .../test/services_list_test.dart | 16 + .../set_delivery_service_request_test.dart | 26 + .../test/sync_contact_channel_test.dart | 27 + .../test/sync_contact_source_test.dart | 56 + .../update_delivery_note_request_test.dart | 22 + .../update_payment_method_request_test.dart | 23 + .../test/update_service_request_test.dart | 36 + pubspec.lock | 14 +- pubspec.yaml | 1 + tool/generate_api_client.sh | 11 + 385 files changed, 29081 insertions(+), 12089 deletions(-) create mode 100644 lib/data/cache/attachment_cache.dart create mode 100644 lib/data/mapper/tour_mapper.dart create mode 100644 lib/data/repository/payment_methods_repository_impl.dart create mode 100644 lib/data/repository/tour_repository_impl.dart create mode 100644 lib/domain/entity/address.dart create mode 100644 lib/domain/entity/article.dart create mode 100644 lib/domain/entity/contact_source.dart create mode 100644 lib/domain/entity/customer.dart create mode 100644 lib/domain/entity/delivery.dart create mode 100644 lib/domain/entity/delivery_credit.dart create mode 100644 lib/domain/entity/delivery_item.dart create mode 100644 lib/domain/entity/delivery_note.dart create mode 100644 lib/domain/entity/delivery_service_value.dart create mode 100644 lib/domain/entity/payment_method.dart create mode 100644 lib/domain/entity/scan_intent.dart create mode 100644 lib/domain/entity/scan_progress.dart create mode 100644 lib/domain/entity/service.dart create mode 100644 lib/domain/entity/tour.dart create mode 100644 lib/domain/entity/tour_details.dart create mode 100644 lib/domain/entity/warehouse.dart create mode 100644 lib/domain/repository/payment_methods_repository.dart create mode 100644 lib/domain/repository/tour_repository.dart delete mode 100644 lib/dto/address.dart delete mode 100644 lib/dto/address.g.dart delete mode 100644 lib/dto/article.dart delete mode 100644 lib/dto/article.g.dart delete mode 100644 lib/dto/basic_response.dart delete mode 100644 lib/dto/basic_response.g.dart delete mode 100644 lib/dto/car.dart delete mode 100644 lib/dto/car.g.dart delete mode 100644 lib/dto/car_add.dart delete mode 100644 lib/dto/car_add.g.dart delete mode 100644 lib/dto/car_add_response.dart delete mode 100644 lib/dto/car_add_response.g.dart delete mode 100644 lib/dto/car_get_response.dart delete mode 100644 lib/dto/car_get_response.g.dart delete mode 100644 lib/dto/component.dart delete mode 100644 lib/dto/component.g.dart delete mode 100644 lib/dto/contact_person.dart delete mode 100644 lib/dto/contact_person.g.dart delete mode 100644 lib/dto/customer.dart delete mode 100644 lib/dto/customer.g.dart delete mode 100644 lib/dto/delivery.dart delete mode 100644 lib/dto/delivery.g.dart delete mode 100644 lib/dto/delivery_response.dart delete mode 100644 lib/dto/delivery_response.g.dart delete mode 100644 lib/dto/delivery_update.dart delete mode 100644 lib/dto/delivery_update.g.dart delete mode 100644 lib/dto/delivery_update_response.dart delete mode 100644 lib/dto/delivery_update_response.g.dart delete mode 100644 lib/dto/discount.dart delete mode 100644 lib/dto/discount.g.dart delete mode 100644 lib/dto/discount_add.dart delete mode 100644 lib/dto/discount_add.g.dart delete mode 100644 lib/dto/discount_add_response.dart delete mode 100644 lib/dto/discount_add_response.g.dart delete mode 100644 lib/dto/discount_remove.dart delete mode 100644 lib/dto/discount_remove.g.dart delete mode 100644 lib/dto/discount_remove_response.dart delete mode 100644 lib/dto/discount_remove_response.g.dart delete mode 100644 lib/dto/discount_update.dart delete mode 100644 lib/dto/discount_update.g.dart delete mode 100644 lib/dto/discount_update_response.dart delete mode 100644 lib/dto/discount_update_response.g.dart delete mode 100644 lib/dto/driver.dart delete mode 100644 lib/dto/driver.g.dart delete mode 100644 lib/dto/image.dart delete mode 100644 lib/dto/image.g.dart delete mode 100644 lib/dto/image_note_response.dart delete mode 100644 lib/dto/image_note_response.g.dart delete mode 100644 lib/dto/note.dart delete mode 100644 lib/dto/note.g.dart delete mode 100644 lib/dto/note_add_response.dart delete mode 100644 lib/dto/note_add_response.g.dart delete mode 100644 lib/dto/note_get_response.dart delete mode 100644 lib/dto/note_get_response.g.dart delete mode 100644 lib/dto/note_template.dart delete mode 100644 lib/dto/note_template.g.dart delete mode 100644 lib/dto/note_template_response.dart delete mode 100644 lib/dto/note_template_response.g.dart delete mode 100644 lib/dto/payment.dart delete mode 100644 lib/dto/payment.g.dart delete mode 100644 lib/dto/payments.dart delete mode 100644 lib/dto/payments.g.dart delete mode 100644 lib/dto/scan.dart delete mode 100644 lib/dto/scan.g.dart delete mode 100644 lib/dto/scan_response.dart delete mode 100644 lib/dto/scan_response.g.dart delete mode 100644 lib/dto/set_article_amount_request.dart delete mode 100644 lib/dto/set_article_amount_request.g.dart delete mode 100644 lib/dto/set_article_amount_response.dart delete mode 100644 lib/dto/set_article_amount_response.g.dart delete mode 100644 lib/exceptions.dart delete mode 100644 lib/feature/delivery/detail/bloc/note_bloc.dart delete mode 100644 lib/feature/delivery/detail/bloc/note_event.dart delete mode 100644 lib/feature/delivery/detail/bloc/note_state.dart create mode 100644 lib/feature/delivery/detail/bloc/workflow_bloc.dart create mode 100644 lib/feature/delivery/detail/bloc/workflow_event.dart create mode 100644 lib/feature/delivery/detail/bloc/workflow_state.dart delete mode 100644 lib/feature/delivery/detail/exceptions.dart delete mode 100644 lib/feature/delivery/detail/model/note.dart delete mode 100644 lib/feature/delivery/detail/presentation/article/article_list.dart delete mode 100644 lib/feature/delivery/detail/presentation/article/article_list_item.dart delete mode 100644 lib/feature/delivery/detail/presentation/article/article_reset_scan_dialog.dart delete mode 100644 lib/feature/delivery/detail/presentation/article/article_unscan_dialog.dart delete mode 100644 lib/feature/delivery/detail/presentation/delivery_discount.dart delete mode 100644 lib/feature/delivery/detail/presentation/delivery_options.dart delete mode 100644 lib/feature/delivery/detail/presentation/delivery_summary.dart delete mode 100644 lib/feature/delivery/detail/presentation/note/note_add_dialog.dart delete mode 100644 lib/feature/delivery/detail/presentation/note/note_edit_dialog.dart delete mode 100644 lib/feature/delivery/detail/presentation/note/note_fail_page.dart delete mode 100644 lib/feature/delivery/detail/presentation/note/note_image_overview.dart delete mode 100644 lib/feature/delivery/detail/presentation/note/note_list.dart delete mode 100644 lib/feature/delivery/detail/presentation/note/note_list_item.dart delete mode 100644 lib/feature/delivery/detail/presentation/note/note_overview.dart delete mode 100644 lib/feature/delivery/detail/presentation/steps/step.dart delete mode 100644 lib/feature/delivery/detail/presentation/steps/step_article_management.dart create mode 100644 lib/feature/delivery/detail/presentation/steps/step_articles.dart delete mode 100644 lib/feature/delivery/detail/presentation/steps/step_delivery_options.dart delete mode 100644 lib/feature/delivery/detail/presentation/steps/step_note.dart create mode 100644 lib/feature/delivery/detail/presentation/steps/step_notes.dart create mode 100644 lib/feature/delivery/detail/presentation/steps/step_services.dart create mode 100644 lib/feature/delivery/detail/presentation/widget/discount_editor.dart delete mode 100644 lib/feature/delivery/detail/repository/delivery_repository.dart delete mode 100644 lib/feature/delivery/detail/repository/note_repository.dart delete mode 100644 lib/feature/delivery/detail/service/notes_service.dart delete mode 100644 lib/feature/delivery/overview/model/sorting_information.dart delete mode 100644 lib/feature/delivery/overview/presentation/delivery_item.dart delete mode 100644 lib/feature/delivery/overview/presentation/delivery_list.dart delete mode 100644 lib/feature/delivery/overview/presentation/delivery_overview_custom_sort.dart delete mode 100644 lib/feature/delivery/overview/service/distance_service.dart delete mode 100644 lib/feature/delivery/overview/service/reorder_service.dart create mode 100644 lib/feature/delivery/pickup/presentation/filiale_pickup_scan_page.dart delete mode 100644 lib/feature/delivery/repository/process_repository.dart delete mode 100644 lib/feature/delivery/repository/tour_repository.dart delete mode 100644 lib/feature/delivery/service/tour_service.dart delete mode 100644 lib/feature/delivery/util.dart create mode 100644 lib/feature/feature_flags.dart delete mode 100644 lib/feature/loading/model/loading_group.dart delete mode 100644 lib/feature/loading/util/loading_order.dart delete mode 100644 lib/feature/loading/widget/article_row.dart delete mode 100644 lib/feature/loading/widget/hold_selection_dialog.dart create mode 100644 lib/feature/loading/widget/reason_catalog.dart delete mode 100644 lib/feature/loading/widget/reason_picker_dialog.dart create mode 100644 lib/feature/loading/widget/reason_picker_sheet.dart create mode 100644 lib/feature/payment_methods/bloc/payment_methods_cubit.dart delete mode 100644 lib/feature/scan/model/article.dart delete mode 100644 lib/feature/scan/presentation/scanner.dart delete mode 100644 lib/feature/scan/repository/scan_repository.dart delete mode 100644 lib/feature/scan/service/scan_service.dart delete mode 100644 lib/feature/scan/util.dart delete mode 100644 lib/model/address.dart delete mode 100644 lib/model/article.dart delete mode 100644 lib/model/car.dart delete mode 100644 lib/model/component.dart delete mode 100644 lib/model/customer.dart delete mode 100644 lib/model/delivery.dart delete mode 100644 lib/model/tour.dart delete mode 100644 lib/model/user.dart delete mode 100644 lib/persistence.dart delete mode 100644 lib/repository.dart delete mode 100644 lib/repository/config.dart delete mode 100644 lib/repository/file.dart delete mode 100644 lib/repository/user_repository.dart delete mode 100644 lib/services/erpframe.dart delete mode 100644 lib/util.dart create mode 100644 lib/widget/attachment_image.dart create mode 100644 lib/widget/scanner/article_scanner_stripe.dart create mode 100644 lib/widget/scanner/item_matcher.dart create mode 100644 lib/widget/scanner/manual_entry_dialog.dart create mode 100644 lib/widget/scanner/scan_code_parser.dart create mode 100644 packages/holzleitner_api/doc/AdminApi.md create mode 100644 packages/holzleitner_api/doc/AttachmentsApi.md create mode 100644 packages/holzleitner_api/doc/CompleteDeliveryAcknowledgements.md create mode 100644 packages/holzleitner_api/doc/ContactChannel.md create mode 100644 packages/holzleitner_api/doc/ContactKind.md create mode 100644 packages/holzleitner_api/doc/ContactRole.md create mode 100644 packages/holzleitner_api/doc/ContactSource.md create mode 100644 packages/holzleitner_api/doc/CreatePaymentMethodRequest.md create mode 100644 packages/holzleitner_api/doc/CreateServiceRequest.md create mode 100644 packages/holzleitner_api/doc/CreditAction.md create mode 100644 packages/holzleitner_api/doc/DeliveredBelegnummernResponse.md create mode 100644 packages/holzleitner_api/doc/DeliveryCredit.md create mode 100644 packages/holzleitner_api/doc/DeliveryCreditEventRequest.md create mode 100644 packages/holzleitner_api/doc/DeliveryCreditResponse.md create mode 100644 packages/holzleitner_api/doc/DeliveryServiceResponse.md create mode 100644 packages/holzleitner_api/doc/DeliveryServiceValue.md create mode 100644 packages/holzleitner_api/doc/ImportSummary.md create mode 100644 packages/holzleitner_api/doc/MarkMailSentRequest.md create mode 100644 packages/holzleitner_api/doc/MarkMailSentResponse.md create mode 100644 packages/holzleitner_api/doc/PaymentMethod.md create mode 100644 packages/holzleitner_api/doc/PaymentMethodResponse.md create mode 100644 packages/holzleitner_api/doc/PaymentMethodsApi.md create mode 100644 packages/holzleitner_api/doc/PaymentMethodsList.md create mode 100644 packages/holzleitner_api/doc/Service.md create mode 100644 packages/holzleitner_api/doc/ServiceKind.md create mode 100644 packages/holzleitner_api/doc/ServiceResponse.md create mode 100644 packages/holzleitner_api/doc/ServicesApi.md create mode 100644 packages/holzleitner_api/doc/ServicesList.md create mode 100644 packages/holzleitner_api/doc/SetDeliveryServiceRequest.md create mode 100644 packages/holzleitner_api/doc/SyncContactChannel.md create mode 100644 packages/holzleitner_api/doc/SyncContactSource.md create mode 100644 packages/holzleitner_api/doc/UpdateDeliveryNoteRequest.md create mode 100644 packages/holzleitner_api/doc/UpdatePaymentMethodRequest.md create mode 100644 packages/holzleitner_api/doc/UpdateServiceRequest.md create mode 100644 packages/holzleitner_api/lib/src/api/admin_api.dart create mode 100644 packages/holzleitner_api/lib/src/api/attachments_api.dart create mode 100644 packages/holzleitner_api/lib/src/api/payment_methods_api.dart create mode 100644 packages/holzleitner_api/lib/src/api/services_api.dart create mode 100644 packages/holzleitner_api/lib/src/model/complete_delivery_acknowledgements.dart create mode 100644 packages/holzleitner_api/lib/src/model/complete_delivery_acknowledgements.g.dart create mode 100644 packages/holzleitner_api/lib/src/model/contact_channel.dart create mode 100644 packages/holzleitner_api/lib/src/model/contact_channel.g.dart create mode 100644 packages/holzleitner_api/lib/src/model/contact_kind.dart create mode 100644 packages/holzleitner_api/lib/src/model/contact_kind.g.dart create mode 100644 packages/holzleitner_api/lib/src/model/contact_role.dart create mode 100644 packages/holzleitner_api/lib/src/model/contact_role.g.dart create mode 100644 packages/holzleitner_api/lib/src/model/contact_source.dart create mode 100644 packages/holzleitner_api/lib/src/model/contact_source.g.dart create mode 100644 packages/holzleitner_api/lib/src/model/create_payment_method_request.dart create mode 100644 packages/holzleitner_api/lib/src/model/create_payment_method_request.g.dart create mode 100644 packages/holzleitner_api/lib/src/model/create_service_request.dart create mode 100644 packages/holzleitner_api/lib/src/model/create_service_request.g.dart create mode 100644 packages/holzleitner_api/lib/src/model/credit_action.dart create mode 100644 packages/holzleitner_api/lib/src/model/credit_action.g.dart create mode 100644 packages/holzleitner_api/lib/src/model/delivered_belegnummern_response.dart create mode 100644 packages/holzleitner_api/lib/src/model/delivered_belegnummern_response.g.dart create mode 100644 packages/holzleitner_api/lib/src/model/delivery_credit.dart create mode 100644 packages/holzleitner_api/lib/src/model/delivery_credit.g.dart create mode 100644 packages/holzleitner_api/lib/src/model/delivery_credit_event_request.dart create mode 100644 packages/holzleitner_api/lib/src/model/delivery_credit_event_request.g.dart create mode 100644 packages/holzleitner_api/lib/src/model/delivery_credit_response.dart create mode 100644 packages/holzleitner_api/lib/src/model/delivery_credit_response.g.dart create mode 100644 packages/holzleitner_api/lib/src/model/delivery_service_response.dart create mode 100644 packages/holzleitner_api/lib/src/model/delivery_service_response.g.dart create mode 100644 packages/holzleitner_api/lib/src/model/delivery_service_value.dart create mode 100644 packages/holzleitner_api/lib/src/model/delivery_service_value.g.dart create mode 100644 packages/holzleitner_api/lib/src/model/import_summary.dart create mode 100644 packages/holzleitner_api/lib/src/model/import_summary.g.dart create mode 100644 packages/holzleitner_api/lib/src/model/mark_mail_sent_request.dart create mode 100644 packages/holzleitner_api/lib/src/model/mark_mail_sent_request.g.dart create mode 100644 packages/holzleitner_api/lib/src/model/mark_mail_sent_response.dart create mode 100644 packages/holzleitner_api/lib/src/model/mark_mail_sent_response.g.dart create mode 100644 packages/holzleitner_api/lib/src/model/payment_method.dart create mode 100644 packages/holzleitner_api/lib/src/model/payment_method.g.dart create mode 100644 packages/holzleitner_api/lib/src/model/payment_method_response.dart create mode 100644 packages/holzleitner_api/lib/src/model/payment_method_response.g.dart create mode 100644 packages/holzleitner_api/lib/src/model/payment_methods_list.dart create mode 100644 packages/holzleitner_api/lib/src/model/payment_methods_list.g.dart create mode 100644 packages/holzleitner_api/lib/src/model/service.dart create mode 100644 packages/holzleitner_api/lib/src/model/service.g.dart create mode 100644 packages/holzleitner_api/lib/src/model/service_kind.dart create mode 100644 packages/holzleitner_api/lib/src/model/service_kind.g.dart create mode 100644 packages/holzleitner_api/lib/src/model/service_response.dart create mode 100644 packages/holzleitner_api/lib/src/model/service_response.g.dart create mode 100644 packages/holzleitner_api/lib/src/model/services_list.dart create mode 100644 packages/holzleitner_api/lib/src/model/services_list.g.dart create mode 100644 packages/holzleitner_api/lib/src/model/set_delivery_service_request.dart create mode 100644 packages/holzleitner_api/lib/src/model/set_delivery_service_request.g.dart create mode 100644 packages/holzleitner_api/lib/src/model/sync_contact_channel.dart create mode 100644 packages/holzleitner_api/lib/src/model/sync_contact_channel.g.dart create mode 100644 packages/holzleitner_api/lib/src/model/sync_contact_source.dart create mode 100644 packages/holzleitner_api/lib/src/model/sync_contact_source.g.dart create mode 100644 packages/holzleitner_api/lib/src/model/update_delivery_note_request.dart create mode 100644 packages/holzleitner_api/lib/src/model/update_delivery_note_request.g.dart create mode 100644 packages/holzleitner_api/lib/src/model/update_payment_method_request.dart create mode 100644 packages/holzleitner_api/lib/src/model/update_payment_method_request.g.dart create mode 100644 packages/holzleitner_api/lib/src/model/update_service_request.dart create mode 100644 packages/holzleitner_api/lib/src/model/update_service_request.g.dart create mode 100644 packages/holzleitner_api/test/admin_api_test.dart create mode 100644 packages/holzleitner_api/test/attachments_api_test.dart create mode 100644 packages/holzleitner_api/test/complete_delivery_acknowledgements_test.dart create mode 100644 packages/holzleitner_api/test/contact_channel_test.dart create mode 100644 packages/holzleitner_api/test/contact_kind_test.dart create mode 100644 packages/holzleitner_api/test/contact_role_test.dart create mode 100644 packages/holzleitner_api/test/contact_source_test.dart create mode 100644 packages/holzleitner_api/test/create_payment_method_request_test.dart create mode 100644 packages/holzleitner_api/test/create_service_request_test.dart create mode 100644 packages/holzleitner_api/test/credit_action_test.dart create mode 100644 packages/holzleitner_api/test/delivered_belegnummern_response_test.dart create mode 100644 packages/holzleitner_api/test/delivery_credit_event_request_test.dart create mode 100644 packages/holzleitner_api/test/delivery_credit_response_test.dart create mode 100644 packages/holzleitner_api/test/delivery_credit_test.dart create mode 100644 packages/holzleitner_api/test/delivery_service_response_test.dart create mode 100644 packages/holzleitner_api/test/delivery_service_value_test.dart create mode 100644 packages/holzleitner_api/test/import_summary_test.dart create mode 100644 packages/holzleitner_api/test/mark_mail_sent_request_test.dart create mode 100644 packages/holzleitner_api/test/mark_mail_sent_response_test.dart create mode 100644 packages/holzleitner_api/test/payment_method_response_test.dart create mode 100644 packages/holzleitner_api/test/payment_method_test.dart create mode 100644 packages/holzleitner_api/test/payment_methods_api_test.dart create mode 100644 packages/holzleitner_api/test/payment_methods_list_test.dart create mode 100644 packages/holzleitner_api/test/service_kind_test.dart create mode 100644 packages/holzleitner_api/test/service_response_test.dart create mode 100644 packages/holzleitner_api/test/service_test.dart create mode 100644 packages/holzleitner_api/test/services_api_test.dart create mode 100644 packages/holzleitner_api/test/services_list_test.dart create mode 100644 packages/holzleitner_api/test/set_delivery_service_request_test.dart create mode 100644 packages/holzleitner_api/test/sync_contact_channel_test.dart create mode 100644 packages/holzleitner_api/test/sync_contact_source_test.dart create mode 100644 packages/holzleitner_api/test/update_delivery_note_request_test.dart create mode 100644 packages/holzleitner_api/test/update_payment_method_request_test.dart create mode 100644 packages/holzleitner_api/test/update_service_request_test.dart diff --git a/docs/BACKEND_MIGRATION.md b/docs/BACKEND_MIGRATION.md index c99a373..1e546dc 100644 --- a/docs/BACKEND_MIGRATION.md +++ b/docs/BACKEND_MIGRATION.md @@ -150,6 +150,41 @@ Smoke: Login → `loadTourOfToday()` → Tour kommt durch. Smoke: Tab-Navigation funktioniert, jeder Tab lädt ohne Crash. +### Phase G — Delivery-Lifecycle-Audit-Log (offen) + +**Status:** geplant, noch nicht begonnen. Aus der Diskussion zur +Wiederherstellung abgebrochener Lieferungen (Phase C+D-4): heute +gehen `state_reason`-Begründungen beim `resume` verloren, weil das +Feld direkt an der `deliveries`-Zeile lebt und beim Wiederherstellen +genullt wird. Item-Aktionen sind sauber auditierbar (`scan_audit`, +append-only) — Delivery-Lifecycle ist es nicht. + +**Scope:** +1. Neue Tabelle `delivery_audit` analog zu `scan_audit`: + - `id`, `delivery_id`, `client_action_id` (UUID UNIQUE — Idempotenz) + - `action` (`hold`|`resume`|`cancel`|`complete`) + - `previous_state`, `resulting_state` + - `reason` (Pflicht bei `hold` / `cancel`) + - `actor_personalnummer`, `actor_car_id?` + - `client_acted_at`, `server_recorded_at` + - denormalisierter ERP-Bezug: `erp_belegart_id`, `erp_belegnummer` +2. Backend: Audit-Insert in jedem `apply_action`-Pfad + (`delivery_repository.rs`). Request-DTOs bekommen Pflichtfelder + `clientActionId` + `clientActedAt`. +3. OpenAPI + Dart-Client neu generieren. +4. App-Bloc: UUID + Timestamp pro Lifecycle-Event mitsenden + (Helper-Funktion analog zur Scan-Pipeline, dort sitzt das Pattern + schon im `TourBloc`). +5. **Optional, zweiter Schritt:** `GET /deliveries/{id}/audit` + plus UI-Anzeige der Historie. Sinnvollste Stelle: im + „Wiederherstellen"-Dialog vom Cancel-Recovery zeigen wir den + ursprünglichen Eintrag („Wurde am … durch … abgebrochen mit + Grund: …"), damit der Fahrer eine bewusste Entscheidung trifft. + +**Out of Scope dieser Phase:** +- `assignCar`-Audit (andere Geste, kein Reason, eigene Sub-Phase). +- Notizen-Audit (Notes sind schon append-only — separate Aktivität). + ### Phase F — Smoke des kompletten Flows Manuell durchklicken: 1. Login (Keycloak) diff --git a/lib/bloc/app_bloc.dart b/lib/bloc/app_bloc.dart index 588a0a0..fcd1ddc 100644 --- a/lib/bloc/app_bloc.dart +++ b/lib/bloc/app_bloc.dart @@ -1,43 +1,20 @@ -import 'dart:convert'; - -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:hl_lieferservice/bloc/app_events.dart'; import 'package:hl_lieferservice/bloc/app_states.dart'; -import 'package:hl_lieferservice/main.dart'; -import 'package:hl_lieferservice/repository/config.dart'; - -import '../services/erpframe.dart'; +/// App-Bootstrap-Bloc. +/// +/// Vor der Backend-Migration lud dieser Bloc eine `hl_server_config.json` aus +/// assets, parste daraus eine `backendUrl` und persistierte sie ins Dateisystem. +/// Mit dem Wechsel auf das Rust-Backend kommt die URL über `BackendConfig` +/// (compile-time, siehe `data/network/backend_config.dart`); der App-Bloc +/// emittiert jetzt nur noch sofort `AppConfigLoaded`, damit die UI ihre +/// üblichen Phasen-Übergänge behält. class AppBloc extends Bloc { - AppBloc() : super(AppInitial()) { - on(_loadConfig); - } - - Future _loadConfig(AppLoadConfig event, Emitter emit) async { - emit(AppConfigLoading()); - try { - final repository = ConfigurationRepository(path: event.path); - final configuration = LocalDocuFrameConfiguration.fromJson( - json.decode(await rootBundle.loadString("assets/${event.path}")), - ); - - repository.setDocuFrameConfiguration(configuration); - - var config = await repository.getDocuFrameConfiguration(); - locator.registerSingleton(config); - - emit(AppConfigLoaded(config: config)); - } catch (e, st) { - debugPrint(e.toString()); - debugPrint(st.toString()); - - emit( - AppConfigLoadingFailed( - message: "Fehler beim Laden der Konfigurationsdatei.", - ), - ); - } + AppBloc() : super(const AppInitial()) { + on((event, emit) { + emit(const AppConfigLoading()); + emit(const AppConfigLoaded()); + }); } } diff --git a/lib/bloc/app_states.dart b/lib/bloc/app_states.dart index 04cd477..551db27 100644 --- a/lib/bloc/app_states.dart +++ b/lib/bloc/app_states.dart @@ -1,16 +1,27 @@ -import '../services/erpframe.dart'; - -abstract class AppState {} - -class AppInitial extends AppState {} -class AppConfigLoading extends AppState {} -class AppConfigLoaded extends AppState { - LocalDocuFrameConfiguration config; - - AppConfigLoaded({required this.config}); +/// Lifecycle-States des App-Bootstraps. +/// +/// Die alte `LocalDocuFrameConfiguration` mit `backendUrl` ist mit der +/// Backend-Migration entfallen — die App-Konfiguration kommt jetzt aus +/// `BackendConfig` (compile-time) und nicht mehr aus einer asset-JSON. +/// Das `AppConfigLoaded`-Signal bleibt als Marker, dass der App-Bootstrap +/// abgeschlossen ist (Networking ist registriert, Token-Provider steht). +abstract class AppState { + const AppState(); } -class AppConfigLoadingFailed extends AppState { - String message; - AppConfigLoadingFailed({required this.message}); -} \ No newline at end of file +class AppInitial extends AppState { + const AppInitial(); +} + +class AppConfigLoading extends AppState { + const AppConfigLoading(); +} + +class AppConfigLoaded extends AppState { + const AppConfigLoaded(); +} + +class AppConfigLoadingFailed extends AppState { + const AppConfigLoadingFailed({required this.message}); + final String message; +} diff --git a/lib/data/cache/attachment_cache.dart b/lib/data/cache/attachment_cache.dart new file mode 100644 index 0000000..2f1553e --- /dev/null +++ b/lib/data/cache/attachment_cache.dart @@ -0,0 +1,162 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:path_provider/path_provider.dart'; + +/// Persistenter Datei-Cache für heruntergeladene Attachment-Vorschauen. +/// +/// **Warum überhaupt ein Cache:** Vorschaubilder werden über +/// `GET /attachments/{id}` aus DOCUframe gerendert — das kostet Zeit und +/// Bandbreite und funktioniert offline gar nicht. Einmal geholte Varianten +/// landen deshalb auf der Platte und werden danach lokal bedient. +/// +/// **Warum keine Content-Revalidierung:** Attachments sind unveränderlich. +/// Ein hochgeladenes Bild (DOCUframe-Objekt) ändert seinen Inhalt nie. Eine +/// einmal gecachte Variante ist daher dauerhaft gültig — kein ETag, kein +/// If-None-Match, kein HEAD nötig. Das Einzige, was den Cache betrifft, ist +/// das **Löschen** eines Attachments; dafür gibt es [retainOnly], das den +/// Cache auf die Menge noch gültiger Attachment-IDs eindampft. +/// +/// **Datei-Layout:** Pro Attachment können mehrere *Varianten* liegen +/// (Thumbnail 600×600, Vollbild 2048×2048, …). Der Dateiname kodiert ID und +/// Render-Parameter: +/// +/// `{attachmentId}__{w}x{h}_q{q}_{ext}` +/// +/// Die ID steht vorne und ist als UUID frei von `__`, sodass [retainOnly] sie +/// zuverlässig wieder herausschneiden kann. +/// +/// Der Cache ist durchweg **best-effort**: Lese-/Schreib-/Lösch-Fehler werden +/// geschluckt und führen höchstens zu einem erneuten Download, nie zu einem +/// Crash. +class AttachmentCache { + AttachmentCache(); + + static const _subdir = 'attachment_previews'; + static const _separator = '__'; + + /// Einmal aufgelöstes Verzeichnis — `getApplicationCacheDirectory` nicht + /// bei jedem Zugriff neu abfragen. + Future? _dirFuture; + + Future _dir() => _dirFuture ??= _resolveDir(); + + Future _resolveDir() async { + final base = await getApplicationCacheDirectory(); + final dir = Directory('${base.path}/$_subdir'); + if (!await dir.exists()) { + await dir.create(recursive: true); + } + return dir; + } + + String _fileName({ + required String attachmentId, + required int w, + required int h, + required int q, + required String ext, + }) => + '$attachmentId$_separator${w}x${h}_q${q}_$ext'; + + Future _file({ + required String attachmentId, + required int w, + required int h, + required int q, + required String ext, + }) async { + final dir = await _dir(); + return File( + '${dir.path}/${_fileName(attachmentId: attachmentId, w: w, h: h, q: q, ext: ext)}', + ); + } + + /// Liest eine gecachte Variante. `null`, wenn nichts da ist oder das Lesen + /// scheitert — der Aufrufer lädt dann frisch. + Future read({ + required String attachmentId, + required int w, + required int h, + required int q, + required String ext, + }) async { + try { + final f = await _file( + attachmentId: attachmentId, + w: w, + h: h, + q: q, + ext: ext, + ); + if (!await f.exists()) return null; + final bytes = await f.readAsBytes(); + return bytes.isEmpty ? null : bytes; + } catch (_) { + return null; + } + } + + /// Schreibt eine Variante. Atomar via temp-Datei + rename, damit ein + /// paralleler Read nie ein halb geschriebenes File sieht. Leere Bytes + /// werden ignoriert (kaputter Download soll keinen leeren Cache-Eintrag + /// hinterlassen). + Future write({ + required String attachmentId, + required int w, + required int h, + required int q, + required String ext, + required Uint8List bytes, + }) async { + if (bytes.isEmpty) return; + try { + final f = await _file( + attachmentId: attachmentId, + w: w, + h: h, + q: q, + ext: ext, + ); + final tmp = File('${f.path}.tmp'); + await tmp.writeAsBytes(bytes, flush: true); + await tmp.rename(f.path); + } catch (_) { + // best-effort + } + } + + /// Entfernt alle gecachten Dateien, deren Attachment-ID **nicht** in + /// [validAttachmentIds] vorkommt. So verschwinden die Vorschauen gelöschter + /// Foto-Notizen beim nächsten Tour-Load aus dem Cache. Verwaiste + /// temp-Dateien (abgebrochene Writes) werden immer mit entfernt. + Future retainOnly(Set validAttachmentIds) async { + try { + final dir = await _dir(); + if (!await dir.exists()) return; + await for (final entity in dir.list()) { + if (entity is! File) continue; + final name = entity.uri.pathSegments.last; + if (name.endsWith('.tmp')) { + await _deleteQuietly(entity); + continue; + } + final sepIdx = name.indexOf(_separator); + final id = sepIdx == -1 ? name : name.substring(0, sepIdx); + if (!validAttachmentIds.contains(id)) { + await _deleteQuietly(entity); + } + } + } catch (_) { + // best-effort — Pruning darf nie den Tour-Load stören + } + } + + Future _deleteQuietly(File f) async { + try { + await f.delete(); + } catch (_) { + // ignore + } + } +} diff --git a/lib/data/mapper/tour_mapper.dart b/lib/data/mapper/tour_mapper.dart new file mode 100644 index 0000000..33a0c8c --- /dev/null +++ b/lib/data/mapper/tour_mapper.dart @@ -0,0 +1,408 @@ +import 'package:holzleitner_api/holzleitner_api.dart' as api; + +import 'package:hl_lieferservice/domain/entity/address.dart'; +import 'package:hl_lieferservice/domain/entity/article.dart'; +import 'package:hl_lieferservice/domain/entity/contact_source.dart'; +import 'package:hl_lieferservice/domain/entity/customer.dart'; +import 'package:hl_lieferservice/domain/entity/delivery.dart'; +import 'package:hl_lieferservice/domain/entity/delivery_credit.dart'; +import 'package:hl_lieferservice/domain/entity/delivery_item.dart'; +import 'package:hl_lieferservice/domain/entity/delivery_note.dart'; +import 'package:hl_lieferservice/domain/entity/delivery_service_value.dart'; +import 'package:hl_lieferservice/domain/entity/service.dart'; +import 'package:hl_lieferservice/domain/entity/payment_method.dart'; +import 'package:hl_lieferservice/domain/entity/scan_intent.dart'; +import 'package:hl_lieferservice/domain/entity/scan_progress.dart'; +import 'package:hl_lieferservice/domain/entity/tour.dart'; +import 'package:hl_lieferservice/domain/entity/tour_details.dart'; +import 'package:hl_lieferservice/domain/entity/warehouse.dart'; + +/// Eine Schicht, ein Mapper-File: alle Übersetzungen vom generierten +/// `built_value`-Client zur Domain. Bewusst pro DTO eine Extension, damit +/// Aufrufer sich nicht in benamten Funktionen verlieren. + +// ─── Primitive ──────────────────────────────────────────────────────────── + +extension ApiAddressMapper on api.Address { + Address toDomain() => Address( + street: street, + houseNumber: houseNumber, + postalCode: postalCode, + city: city, + country: country, + ); +} + +// ─── Stammdaten ─────────────────────────────────────────────────────────── + +extension ApiWarehouseMapper on api.Warehouse { + Warehouse toDomain() => Warehouse( + id: id, + name: name, + code: code, + isStandard: isStandard, + ); +} + +extension ApiArticleMapper on api.Article { + Article toDomain() => Article( + id: id, + articleNumber: articleNumber, + name: name, + scannable: scannable, + defaultWarehouseId: defaultWarehouseId, + ); +} + +extension ApiCustomerMapper on api.Customer { + Customer toDomain() => Customer( + id: id, + name: name, + erpCustomerId: erpCustomerId, + address: address.toDomain(), + ); +} + +extension ApiCustomerContactMapper on api.CustomerContact { + CustomerContact toDomain() => CustomerContact( + id: id, + customerId: customerId, + name: name, + phone: phone, + email: email, + ); +} + +// ─── Beleg-Kontaktquellen (ContactSource / ContactChannel) ─────────────── +// +// Der OpenAPI-Generator erzeugt für die snake-case-serde-Enums im Backend +// `EnumClass`-Wrapper mit camelCase-Identifiern. Verglichen wird wie bei +// `ScanStatus` per Identitäts-Check; Fallback ist ein StateError, damit +// neue Backend-Werte sofort auffallen statt schweigend zu mappen. + +extension ApiContactRoleMapper on api.ContactRole { + ContactRole toDomain() { + if (this == api.ContactRole.header) return ContactRole.header; + if (this == api.ContactRole.delivery) return ContactRole.delivery; + if (this == api.ContactRole.billing) return ContactRole.billing; + if (this == api.ContactRole.contactPerson) return ContactRole.contactPerson; + if (this == api.ContactRole.customerMaster) { + return ContactRole.customerMaster; + } + throw StateError('Unbekannte ContactRole vom Backend: $this'); + } +} + +extension ApiContactKindMapper on api.ContactKind { + ContactKind toDomain() { + if (this == api.ContactKind.phone) return ContactKind.phone; + if (this == api.ContactKind.mobile) return ContactKind.mobile; + if (this == api.ContactKind.email) return ContactKind.email; + if (this == api.ContactKind.web) return ContactKind.web; + throw StateError('Unbekannter ContactKind vom Backend: $this'); + } +} + +extension ApiContactSourceMapper on api.ContactSource { + ContactSource toDomain() => ContactSource( + id: id, + deliveryId: deliveryId, + role: role.toDomain(), + anrede: anrede, + titel: titel, + name1: name1, + name2: name2, + name3: name3, + abteilung: abteilung, + funktion: funktion, + ); +} + +extension ApiContactChannelMapper on api.ContactChannel { + ContactChannel toDomain() => ContactChannel( + id: id, + sourceId: sourceId, + kind: kind.toDomain(), + position: position, + value: value, + ); +} + +// ─── Scan-Progress & Delivery-Item ─────────────────────────────────────── + +extension ApiScanStateMapper on api.ScanState { + ScanProgress toDomain() => ScanProgress( + status: status.toDomain(), + scannedQuantity: scannedQuantity, + creditedQuantity: creditedQuantity, + lastUpdatedAt: lastUpdatedAt, + heldReason: heldReason, + ); +} + +// ─── Scan-Apply ────────────────────────────────────────────────────────── + +extension DomainScanActionMapper on ScanAction { + api.AuditAction toWire() { + switch (this) { + case ScanAction.scan: + return api.AuditAction.scan; + case ScanAction.unscan: + return api.AuditAction.unscan; + case ScanAction.hold: + return api.AuditAction.hold; + case ScanAction.unhold: + return api.AuditAction.unhold; + case ScanAction.remove: + return api.AuditAction.remove; + case ScanAction.unremove: + return api.AuditAction.unremove; + } + } +} + +extension DomainScanIntentMapper on ScanIntent { + api.ScanEvent toWire() => api.ScanEvent((b) { + b + ..clientScanId = clientScanId + ..clientScannedAt = clientScannedAt.toUtc() + ..deliveryItemId = deliveryItemId + ..action = action.toWire() + ..actorCarId = actorCarId + ..reason = reason + // Nur für remove/unremove relevant; null = ganze Restmenge. + ..quantity = quantity + ..manual = manual; + }); +} + +extension ApiScanResultStatusMapper on api.ScanResultStatus { + ScanOutcomeStatus toDomain() { + if (this == api.ScanResultStatus.applied) return ScanOutcomeStatus.applied; + if (this == api.ScanResultStatus.duplicate) { + return ScanOutcomeStatus.duplicate; + } + if (this == api.ScanResultStatus.rejected) { + return ScanOutcomeStatus.rejected; + } + throw StateError('Unbekannter ScanResultStatus vom Backend: $this'); + } +} + +extension ApiScanResultMapper on api.ScanResult { + ScanOutcome toDomain() => ScanOutcome( + clientScanId: clientScanId, + status: status.toDomain(), + deliveryItemId: deliveryItemId, + reason: reason, + ); +} + +extension ApiScanStatusMapper on api.ScanStatus { + ScanStatus toDomain() { + // EnumClass kennt keinen `switch`-Exhaustiveness-Check; deshalb explizit. + if (this == api.ScanStatus.inProgress) return ScanStatus.inProgress; + if (this == api.ScanStatus.done) return ScanStatus.done; + if (this == api.ScanStatus.held) return ScanStatus.held; + if (this == api.ScanStatus.removed) return ScanStatus.removed; + throw StateError('Unbekannter ScanStatus vom Backend: $this'); + } +} + +extension ApiDeliveryItemMapper on api.DeliveryItem { + DeliveryItem toDomain() => DeliveryItem( + id: id, + deliveryId: deliveryId, + articleId: articleId, + warehouseId: warehouseId, + belegzeilenNr: belegzeilenNr, + requiredQuantity: requiredQuantity, + scanProgress: scanState.toDomain(), + unitPrice: unitPrice, + komponentenArtikelNr: komponentenArtikelNr, + parentArtikelNr: parentArtikelNr, + ); +} + +// ─── Delivery ──────────────────────────────────────────────────────────── + +extension ApiDeliveryStateMapper on api.DeliveryState { + DeliveryState toDomain() { + if (this == api.DeliveryState.active) return DeliveryState.active; + if (this == api.DeliveryState.held) return DeliveryState.held; + if (this == api.DeliveryState.canceled) return DeliveryState.canceled; + if (this == api.DeliveryState.completed) return DeliveryState.completed; + throw StateError('Unbekannter DeliveryState vom Backend: $this'); + } +} + +extension ApiDeliveryWithItemsMapper on api.DeliveryWithItems { + Delivery toDomain() => Delivery( + id: id, + tourId: tourId, + customerId: customerId, + contactPersonIds: contactPersonIds.toList(growable: false), + deliveryAddressSnapshot: deliveryAddressSnapshot.toDomain(), + erpBelegartId: erpBelegartId, + erpBelegnummer: erpBelegnummer, + state: state.toDomain(), + stateReason: stateReason, + sortOrder: sortOrder, + assignedCarId: assignedCarId, + desiredTime: desiredTime, + specialAgreements: specialAgreements, + items: items.map((it) => it.toDomain()).toList(growable: false), + prepaidAmount: prepaidAmount, + paymentMethodId: paymentMethodId, + ); +} + +extension ApiPaymentMethodMapper on api.PaymentMethod { + PaymentMethod toDomain() => PaymentMethod( + id: id, + code: code, + name: name, + active: active, + createdAt: createdAt, + ); +} + +// ─── Tour-Notiz ────────────────────────────────────────────────────────── + +extension ApiDeliveryNoteMapper on api.DeliveryNote { + DeliveryNote toDomain() => DeliveryNote( + id: id, + deliveryId: deliveryId, + text: text, + imageAttachment: imageAttachment, + authorPersonalnummer: authorPersonalnummer, + authorCarId: authorCarId, + creditDeliveryItemId: creditDeliveryItemId, + isAmountCreditNote: isAmountCreditNote, + imageAttachmentDeleted: imageAttachmentDeleted ?? false, + createdAt: createdAt, + ); +} + +// ─── Tour-Wurzel ───────────────────────────────────────────────────────── + +extension ApiTourMapper on api.Tour { + Tour toDomain() => Tour( + id: id, + accountId: accountId, + date: date.toDateTime(), + syncedAt: syncedAt, + ); +} + +extension ApiTourSummaryMapper on api.TourSummary { + TourSummary toDomain() => TourSummary( + tourId: tourId, + tourDate: tourDate.toDateTime(), + deliveryCount: deliveryCount, + ); +} + +extension ApiTourDetailsMapper on api.TourDetails { + TourDetails toDomain() { + final customersMap = { + for (final c in customers) c.id: c.toDomain(), + }; + final contactsMap = { + for (final c in customerContacts) c.id: c.toDomain(), + }; + final articlesMap = { + for (final a in articles) a.id: a.toDomain(), + }; + final warehousesMap = { + for (final w in warehouses) w.id: w.toDomain(), + }; + + // Notizen sind im Wire flach — pro Lieferung indizieren und aufsteigend + // nach createdAt sortieren, damit das UI sich nicht jedes Mal selbst + // sortieren muss. + final notesGrouped = >{}; + for (final n in notes) { + final domain = n.toDomain(); + (notesGrouped[domain.deliveryId] ??= []).add(domain); + } + for (final list in notesGrouped.values) { + list.sort((a, b) => a.createdAt.compareTo(b.createdAt)); + } + + // Gutschriften: höchstens eine pro Lieferung (aktueller Stand). + final creditsMap = { + for (final c in credits) c.deliveryId: c.toDomain(), + }; + + // Service-Definitionen (aktiv, sortiert) + Pro-Lieferung-Werte indizieren. + final servicesList = + services.map((s) => s.toDomain()).toList(growable: false); + final serviceValues = >{}; + for (final v in deliveryServices) { + (serviceValues[v.deliveryId] ??= {})[ + v.serviceId] = v.toDomain(); + } + + // Kontaktquellen pro Lieferung gruppieren; Kanäle pro Quelle gruppieren. + // Backend liefert sie sortiert (Quellen nach Rolle, Kanäle nach kind + + // position) — wir behalten die Reihenfolge bei. + final sourcesGrouped = >{}; + for (final s in contactSources) { + final domain = s.toDomain(); + (sourcesGrouped[domain.deliveryId] ??= []).add(domain); + } + final channelsGrouped = >{}; + for (final c in contactChannels) { + final domain = c.toDomain(); + (channelsGrouped[domain.sourceId] ??= []).add(domain); + } + + return TourDetails( + tour: tour.toDomain(), + deliveries: deliveries.map((d) => d.toDomain()).toList(growable: false), + customers: customersMap, + contacts: contactsMap, + articles: articlesMap, + warehouses: warehousesMap, + notesByDeliveryId: notesGrouped, + creditsByDeliveryId: creditsMap, + services: servicesList, + serviceValuesByDeliveryId: serviceValues, + contactSourcesByDeliveryId: sourcesGrouped, + contactChannelsBySourceId: channelsGrouped, + ); + } +} + +extension ApiServiceMapper on api.Service { + Service toDomain() => Service( + id: id, + key: key, + name: name, + kind: kind == api.ServiceKind.numeric + ? ServiceKind.numeric + : ServiceKind.boolean, + active: active, + sortOrder: sortOrder, + minValue: minValue, + maxValue: maxValue, + ); +} + +extension ApiDeliveryServiceValueMapper on api.DeliveryServiceValue { + DeliveryServiceValue toDomain() => DeliveryServiceValue( + deliveryId: deliveryId, + serviceId: serviceId, + boolValue: boolValue, + numericValue: numericValue, + ); +} + +extension ApiDeliveryCreditMapper on api.DeliveryCredit { + DeliveryCredit toDomain() => DeliveryCredit( + deliveryId: deliveryId, + amountCents: amountCents, + reason: reason, + ); +} diff --git a/lib/data/network/backend_config.dart b/lib/data/network/backend_config.dart index 2d0fcdd..ca46934 100644 --- a/lib/data/network/backend_config.dart +++ b/lib/data/network/backend_config.dart @@ -53,4 +53,44 @@ class BackendConfig { keycloakClientId: 'holzleitner-app', keycloakRedirectUrl: 'holzleitner://oauth2redirect', ); + + /// Konfiguration für USB-Tunnel via `adb reverse` — gedacht für Tests in + /// fremden Netzwerken, in denen das Gerät den Mac nicht über eine LAN-IP + /// erreicht. Alles zeigt auf `localhost`; der Traffic wird über den + /// USB-Bus zum Host getunnelt. + /// + /// **Setup vor dem Start (Gerät per USB angesteckt):** + /// ``` + /// adb reverse tcp:3000 tcp:3000 # Rust-API + /// adb reverse tcp:8080 tcp:8080 # Keycloak + /// ``` + /// + /// **Backend-Voraussetzungen**, damit das OIDC-Login funktioniert: + /// * Backend-Env `KEYCLOAK_ISSUER_URL=http://localhost:8080/realms/holzleitner` + /// (muss exakt mit [keycloakIssuerUrl] matchen, sonst 401 `invalid issuer`). + /// * Keycloak muss den Issuer als `localhost` ausgeben — z. B. via + /// `KC_HOSTNAME_URL=http://localhost:8080` (oder Frontend-URL im Realm), + /// sonst prägt es den Container-Hostnamen ins `iss`-Claim. + /// * Der `holzleitner://oauth2redirect`-Redirect bleibt unverändert (das + /// Custom-Scheme ist netzwerk-unabhängig). + /// + /// Aktivieren ohne Code-Edit: + /// ``` + /// flutter run --dart-define=HL_BACKEND=usb + /// ``` + static const BackendConfig usbReverse = BackendConfig( + apiBaseUrl: 'http://localhost:3000', + keycloakIssuerUrl: 'http://localhost:8080/realms/holzleitner', + keycloakClientId: 'holzleitner-app', + keycloakRedirectUrl: 'holzleitner://oauth2redirect', + ); + + /// Wählt die Config anhand des Compile-Time-Flags `HL_BACKEND`: + /// * `usb` → [usbReverse] (adb-reverse-Tunnel über localhost) + /// * sonst → [localDev] (LAN-IP, Default) + /// + /// So muss für einen Netzwerkwechsel nur das Build-Flag gesetzt werden, + /// nicht der Quellcode angefasst. + static const BackendConfig fromEnvironment = + String.fromEnvironment('HL_BACKEND') == 'usb' ? usbReverse : localDev; } diff --git a/lib/data/network/keycloak_oidc_token_provider.dart b/lib/data/network/keycloak_oidc_token_provider.dart index 9227c13..4353539 100644 --- a/lib/data/network/keycloak_oidc_token_provider.dart +++ b/lib/data/network/keycloak_oidc_token_provider.dart @@ -55,6 +55,13 @@ class KeycloakOidcTokenProvider implements AuthTokenProvider { String? _refreshToken; Map? _idTokenClaims; + /// Single-flight-Guard: hält den gerade laufenden Refresh, damit mehrere + /// gleichzeitige Aufrufer (Bootstrap: Restore + PaymentMethodsCubit + + /// Folge-Requests) sich EINEN Refresh teilen statt parallele + /// `flutter_appauth.token()`-Calls auszulösen (die nativ blockieren/haken + /// können → App hängt nach Hot-Restart am Splash/Login). + Future? _refreshInFlight; + final StreamController _events = StreamController.broadcast(); @@ -166,6 +173,19 @@ class KeycloakOidcTokenProvider implements AuthTokenProvider { final rt = _refreshToken; if (rt == null) return null; + // Single-flight: läuft bereits ein Refresh, hängen wir uns dran, statt + // einen zweiten `flutter_appauth.token()`-Call zu starten. `??=` + // evaluiert die rechte Seite nur, wenn noch kein Refresh läuft. + return _refreshInFlight ??= _performRefresh(rt).whenComplete(() { + _refreshInFlight = null; + }); + } + + /// Führt EINEN Token-Refresh aus. Bei Erfolg werden die Tokens übernommen + /// und der neue Access-Token zurückgegeben (ohne Event — stiller Refresh). + /// Bei Fehler ist die Session tot: lokal aufräumen, `AuthSessionExpired` + /// emittieren, `null` zurück. + Future _performRefresh(String rt) async { try { final result = await _appAuth.token( TokenRequest( @@ -187,11 +207,17 @@ class KeycloakOidcTokenProvider implements AuthTokenProvider { return _accessToken; } on Exception { // Refresh hat nicht funktioniert — Session ist tot, nicht - // wiederherstellbar. Aufrufer kriegen null zurück, AuthBloc - // bekommt SessionExpired. + // wiederherstellbar. Reihenfolge bewusst: erst State leeren + Event + // feuern, DANN best-effort den Storage löschen — so kann ein + // werfendes `delete` weder das Event verschlucken noch eine Exception + // aus `currentAccessToken()` leaken. _clearSession(); - await _storage.delete(key: _refreshTokenStorageKey); _events.add(const AuthSessionExpired()); + try { + await _storage.delete(key: _refreshTokenStorageKey); + } catch (e) { + debugPrint('currentAccessToken: Refresh-Token-Delete fehlgeschlagen: $e'); + } return null; } } diff --git a/lib/data/network/network_locator.dart b/lib/data/network/network_locator.dart index 4cf5edb..b554e19 100644 --- a/lib/data/network/network_locator.dart +++ b/lib/data/network/network_locator.dart @@ -17,7 +17,7 @@ import 'keycloak_oidc_token_provider.dart'; /// das reine dart-Smoke-Tool, siehe `tool/smoke_test_api.dart`). void registerNetworking({ required GetIt locator, - BackendConfig config = BackendConfig.localDev, + BackendConfig config = BackendConfig.fromEnvironment, }) { locator.registerSingleton(config); diff --git a/lib/data/repository/payment_methods_repository_impl.dart b/lib/data/repository/payment_methods_repository_impl.dart new file mode 100644 index 0000000..ecee445 --- /dev/null +++ b/lib/data/repository/payment_methods_repository_impl.dart @@ -0,0 +1,125 @@ +import 'package:dio/dio.dart'; +import 'package:holzleitner_api/holzleitner_api.dart' as api; + +import 'package:hl_lieferservice/data/mapper/tour_mapper.dart'; +import 'package:hl_lieferservice/domain/entity/payment_method.dart'; +import 'package:hl_lieferservice/domain/repository/payment_methods_repository.dart'; + +/// Dio-Impl gegen den generierten `PaymentMethodsApi`. +/// +/// Fehler-Mapping: +/// * `409 Conflict` (UNIQUE-Verletzung oder FK-RESTRICT beim Löschen) +/// → `PaymentMethodsRepositoryException` mit klarer Meldung. +/// * `404` → dito (NotFound-Hinweis im Text). +/// * `401` lassen wir ungefangen durchfliegen — globaler Auth-Handler +/// übernimmt. +class PaymentMethodsRepositoryImpl implements PaymentMethodsRepository { + PaymentMethodsRepositoryImpl(this._api); + + final api.HolzleitnerApi _api; + + @override + Future> list({bool includeInactive = false}) async { + try { + final response = await _api + .getPaymentMethodsApi() + .listPaymentMethods(includeInactive: includeInactive); + final methods = response.data?.methods; + if (methods == null) return const []; + return methods.map((m) => m.toDomain()).toList(growable: false); + } on DioException catch (e) { + throw PaymentMethodsRepositoryException( + _describe(e, 'Laden der Zahlungsmethoden'), + e, + ); + } + } + + @override + Future create({ + required String code, + required String name, + }) async { + try { + final request = api.CreatePaymentMethodRequest((b) { + b + ..code = code + ..name = name; + }); + final response = await _api + .getPaymentMethodsApi() + .createPaymentMethod(createPaymentMethodRequest: request); + final method = response.data?.method; + if (method == null) { + throw const PaymentMethodsRepositoryException( + 'Server lieferte leere Antwort beim Anlegen', + ); + } + return method.toDomain(); + } on DioException catch (e) { + throw PaymentMethodsRepositoryException( + _describe(e, 'Anlegen einer Zahlungsmethode'), + e, + ); + } + } + + @override + Future update({ + required String id, + String? name, + bool? active, + }) async { + try { + final request = api.UpdatePaymentMethodRequest((b) { + if (name != null) b.name = name; + if (active != null) b.active = active; + }); + final response = + await _api.getPaymentMethodsApi().updatePaymentMethod( + id: id, + updatePaymentMethodRequest: request, + ); + final method = response.data?.method; + if (method == null) { + throw const PaymentMethodsRepositoryException( + 'Server lieferte leere Antwort beim Aktualisieren', + ); + } + return method.toDomain(); + } on DioException catch (e) { + throw PaymentMethodsRepositoryException( + _describe(e, 'Aktualisieren einer Zahlungsmethode'), + e, + ); + } + } + + @override + Future delete(String id) async { + try { + await _api.getPaymentMethodsApi().deletePaymentMethod(id: id); + } on DioException catch (e) { + throw PaymentMethodsRepositoryException( + _describe(e, 'Löschen einer Zahlungsmethode'), + e, + ); + } + } + + String _describe(DioException e, String operation) { + final status = e.response?.statusCode; + final body = e.response?.data; + if ((status == 400 || status == 409) && + body is Map && + body['message'] != null) { + return body['message'].toString(); + } + if (status == 409) { + return 'Zahlungsmethode wird noch von Lieferungen verwendet'; + } + if (status == 404) return 'Zahlungsmethode nicht gefunden'; + if (status == 401) return 'Sitzung abgelaufen'; + return '$operation fehlgeschlagen (HTTP ${status ?? 'unbekannt'})'; + } +} diff --git a/lib/data/repository/tour_repository_impl.dart b/lib/data/repository/tour_repository_impl.dart new file mode 100644 index 0000000..9ec6c22 --- /dev/null +++ b/lib/data/repository/tour_repository_impl.dart @@ -0,0 +1,569 @@ +import 'dart:convert'; + +import 'package:dio/dio.dart'; +import 'package:holzleitner_api/holzleitner_api.dart' as api; + +import 'package:hl_lieferservice/data/mapper/tour_mapper.dart'; +import 'package:hl_lieferservice/domain/entity/address.dart'; +import 'package:hl_lieferservice/domain/entity/delivery.dart'; +import 'package:hl_lieferservice/domain/entity/delivery_credit.dart'; +import 'package:hl_lieferservice/domain/entity/delivery_note.dart'; +import 'package:hl_lieferservice/domain/entity/delivery_service_value.dart'; +import 'package:hl_lieferservice/domain/entity/scan_intent.dart'; +import 'package:hl_lieferservice/domain/entity/tour.dart'; +import 'package:hl_lieferservice/domain/entity/tour_details.dart'; +import 'package:hl_lieferservice/domain/repository/tour_repository.dart'; + +/// Implementierung gegen den generierten Dio-Client. Übersetzt +/// `DioException` in [TourRepositoryException]. 401 lassen wir +/// ungefangen durchfliegen — der TokenProvider erkennt 401 separat +/// und meldet `AuthSessionExpired`. +class TourRepositoryImpl implements TourRepository { + TourRepositoryImpl(this._api); + + final api.HolzleitnerApi _api; + + @override + Future getMyTourSummaryOfToday() async { + try { + final response = await _api.getToursApi().listMyToursToday(); + final tours = response.data?.tours; + if (tours == null || tours.isEmpty) return null; + // Backend liefert die Liste sortiert; wir nehmen die erste Tour. + // Der Fahrer hat aktuell nur eine Tour pro Tag — falls sich das + // ändert, wird hier eine Auswahl-UI nötig. + return tours.first.toDomain(); + } on DioException catch (e) { + throw TourRepositoryException(_describe(e, 'Laden der Tour-Übersicht'), e); + } + } + + @override + Future getTourDetails(String tourId) async { + try { + final response = await _api.getToursApi().getTour(tourId: tourId); + final details = response.data; + if (details == null) { + throw const TourRepositoryException( + 'Server lieferte leere Tour-Antwort', + ); + } + return details.toDomain(); + } on DioException catch (e) { + throw TourRepositoryException(_describe(e, 'Laden der Tour'), e); + } + } + + @override + Future getMyTourDetailsOfToday() async { + final summary = await getMyTourSummaryOfToday(); + if (summary == null) return null; + return getTourDetails(summary.tourId); + } + + @override + Future> setDeliveryOrder({ + required String tourId, + required List orderedDeliveryIds, + }) async { + try { + final request = api.SetDeliveryOrderRequest((b) { + b.deliveryIds.replace(orderedDeliveryIds); + }); + final response = await _api.getToursApi().setDeliveryOrder( + tourId: tourId, + setDeliveryOrderRequest: request, + ); + final order = response.data?.order; + if (order == null) { + throw const TourRepositoryException( + 'Server lieferte leere Reihenfolge-Antwort', + ); + } + return {for (final e in order) e.deliveryId: e.sortOrder}; + } on DioException catch (e) { + throw TourRepositoryException(_describe(e, 'Speichern der Reihenfolge'), e); + } + } + + @override + Future assignCarToDelivery({ + required String deliveryId, + required String? carId, + }) async { + try { + final request = api.AssignCarRequest((b) { + // `null` ⇒ Backend hebt die Zuweisung auf. built_value lässt das + // im Wire als `"carId": null` durchgehen — vom OpenAPI-Schema so + // gewollt. + b.carId = carId; + }); + final response = await _api.getDeliveriesApi().assignCar( + deliveryId: deliveryId, + assignCarRequest: request, + ); + final delivery = response.data?.delivery; + if (delivery == null) { + throw const TourRepositoryException( + 'Server lieferte leere Delivery-Antwort', + ); + } + // Achtung: Der Endpoint gibt `Delivery` *ohne* Items zurück. + // Wir bauen hier eine Domain-Delivery mit leerer Item-Liste — + // der Bloc muss die echten Items aus dem lokalen Aggregat mergen. + return Delivery( + id: delivery.id, + tourId: delivery.tourId, + customerId: delivery.customerId, + contactPersonIds: delivery.contactPersonIds.toList(growable: false), + deliveryAddressSnapshot: delivery.deliveryAddressSnapshot.toDomain(), + erpBelegartId: delivery.erpBelegartId, + erpBelegnummer: delivery.erpBelegnummer, + state: delivery.state.toDomain(), + stateReason: delivery.stateReason, + // Stamm-Endpoint kennt `sortOrder` nicht — Bloc behält den Wert. + sortOrder: 0, + assignedCarId: delivery.assignedCarId, + desiredTime: delivery.desiredTime, + specialAgreements: delivery.specialAgreements, + items: const [], + prepaidAmount: delivery.prepaidAmount, + paymentMethodId: delivery.paymentMethodId, + ); + } on DioException catch (e) { + throw TourRepositoryException(_describe(e, 'Fahrzeug-Zuweisung'), e); + } + } + + @override + Future cancelDelivery({ + required String deliveryId, + required String reason, + }) async { + try { + final request = api.CancelDeliveryRequest((b) => b..reason = reason); + final response = await _api.getDeliveriesApi().cancel( + deliveryId: deliveryId, + cancelDeliveryRequest: request, + ); + return _liftDeliveryStub(response.data?.delivery, 'Abbruch'); + } on DioException catch (e) { + throw TourRepositoryException(_describe(e, 'Lieferung abbrechen'), e); + } + } + + @override + Future holdDelivery({ + required String deliveryId, + required String reason, + }) async { + try { + final request = api.HoldDeliveryRequest((b) => b..reason = reason); + final response = await _api.getDeliveriesApi().hold( + deliveryId: deliveryId, + holdDeliveryRequest: request, + ); + return _liftDeliveryStub(response.data?.delivery, 'Pausieren'); + } on DioException catch (e) { + throw TourRepositoryException(_describe(e, 'Lieferung pausieren'), e); + } + } + + @override + Future resumeDelivery({required String deliveryId}) async { + try { + final response = + await _api.getDeliveriesApi().resume(deliveryId: deliveryId); + return _liftDeliveryStub(response.data?.delivery, 'Fortsetzen'); + } on DioException catch (e) { + throw TourRepositoryException(_describe(e, 'Lieferung fortsetzen'), e); + } + } + + @override + Future completeDelivery({ + required String deliveryId, + required List customerSignaturePng, + required List driverSignaturePng, + required bool receiptConfirmed, + required bool notesAcknowledged, + required List acknowledgedNoteIds, + String? paymentMethodId, + String? actorCarId, + bool paymentCollected = false, + }) async { + // multipart/form-data: zwei Signatur-PNGs + ein JSON-Feld mit den + // Bestätigungen. Direkt über die Dio-Instanz, weil der dart-dio-Generator + // für multipart keinen typisierten Body erzeugt (wie beim Bild-Upload). + try { + final acknowledgements = { + 'receiptConfirmed': receiptConfirmed, + 'notesAcknowledged': notesAcknowledged, + 'acknowledgedNoteIds': acknowledgedNoteIds, + 'paymentCollected': paymentCollected, + if (paymentMethodId != null) 'paymentMethodId': paymentMethodId, + if (actorCarId != null) 'authorCarId': actorCarId, + }; + final form = FormData.fromMap({ + 'customer_signature': MultipartFile.fromBytes( + customerSignaturePng, + filename: 'customer_signature.png', + contentType: DioMediaType.parse('image/png'), + ), + 'driver_signature': MultipartFile.fromBytes( + driverSignaturePng, + filename: 'driver_signature.png', + contentType: DioMediaType.parse('image/png'), + ), + 'acknowledgements': jsonEncode(acknowledgements), + }); + final response = await _api.dio.post>( + '/deliveries/$deliveryId/complete', + data: form, + ); + final delivery = response.data?['delivery'] as Map?; + if (delivery == null) { + throw const TourRepositoryException( + 'Server lieferte leere Delivery-Antwort beim Abschließen', + ); + } + return _deliveryStubFromJson(delivery); + } on DioException catch (e) { + throw TourRepositoryException(_describe(e, 'Lieferung abschließen'), e); + } + } + + /// Hebt einen Delivery-Stub (Stamm-Endpoint-Response **ohne** Items) + /// in die Domain. Aufrufer muss anschließend `copyWith(items: ..., sortOrder: ...)` + /// aus dem lokalen Aggregat mergen — der Bloc-Handler kümmert sich darum. + Delivery _liftDeliveryStub(api.Delivery? stub, String operation) { + if (stub == null) { + throw TourRepositoryException( + 'Server lieferte leere Delivery-Antwort beim $operation', + ); + } + return Delivery( + id: stub.id, + tourId: stub.tourId, + customerId: stub.customerId, + contactPersonIds: stub.contactPersonIds.toList(growable: false), + deliveryAddressSnapshot: stub.deliveryAddressSnapshot.toDomain(), + erpBelegartId: stub.erpBelegartId, + erpBelegnummer: stub.erpBelegnummer, + state: stub.state.toDomain(), + stateReason: stub.stateReason, + sortOrder: 0, + assignedCarId: stub.assignedCarId, + desiredTime: stub.desiredTime, + specialAgreements: stub.specialAgreements, + items: const [], + prepaidAmount: stub.prepaidAmount, + paymentMethodId: stub.paymentMethodId, + ); + } + + @override + Future addDeliveryNote({ + required String deliveryId, + String? text, + String? imageAttachment, + String? creditDeliveryItemId, + bool isAmountCreditNote = false, + }) async { + try { + final request = api.CreateDeliveryNoteRequest((b) { + if (text != null) b.text = text; + if (imageAttachment != null) b.imageAttachment = imageAttachment; + if (creditDeliveryItemId != null) { + b.creditDeliveryItemId = creditDeliveryItemId; + } + b.isAmountCreditNote = isAmountCreditNote; + }); + final response = await _api.getDeliveriesApi().createNote( + deliveryId: deliveryId, + createDeliveryNoteRequest: request, + ); + final note = response.data?.note; + if (note == null) { + throw const TourRepositoryException( + 'Server lieferte leere Notiz-Antwort', + ); + } + return note.toDomain(); + } on DioException catch (e) { + throw TourRepositoryException(_describe(e, 'Notiz anlegen'), e); + } + } + + @override + Future updateDeliveryNote({ + required String deliveryId, + required String noteId, + String? text, + String? imageAttachment, + }) async { + try { + final request = api.UpdateDeliveryNoteRequest((b) { + if (text != null) b.text = text; + if (imageAttachment != null) b.imageAttachment = imageAttachment; + }); + final response = await _api.getDeliveriesApi().updateNote( + deliveryId: deliveryId, + noteId: noteId, + updateDeliveryNoteRequest: request, + ); + final note = response.data?.note; + if (note == null) { + throw const TourRepositoryException( + 'Server lieferte leere Notiz-Antwort', + ); + } + return note.toDomain(); + } on DioException catch (e) { + throw TourRepositoryException(_describe(e, 'Notiz aktualisieren'), e); + } + } + + @override + Future deleteDeliveryNote({ + required String deliveryId, + required String noteId, + }) async { + try { + await _api.getDeliveriesApi().deleteNote( + deliveryId: deliveryId, + noteId: noteId, + ); + } on DioException catch (e) { + throw TourRepositoryException(_describe(e, 'Notiz löschen'), e); + } + } + + @override + Future uploadDeliveryNoteImage({ + required String deliveryId, + required String filename, + required String mime, + required List bytes, + }) async { + // Bewusst direkt über die Dio-Instanz statt über den generierten Client: + // der dart-dio-Generator erzeugt für multipart/form-data keinen + // typisierten Body-Parameter. Der `HolzleitnerAuthInterceptor` an der + // Dio-Instanz hängt den Bearer-Token automatisch an. + try { + final form = FormData.fromMap({ + 'file': MultipartFile.fromBytes( + bytes, + filename: filename, + contentType: DioMediaType.parse(mime), + ), + }); + final response = await _api.dio.post>( + '/deliveries/$deliveryId/notes/image', + data: form, + ); + final note = (response.data?['note']) as Map?; + if (note == null) { + throw const TourRepositoryException( + 'Server lieferte leere Notiz-Antwort beim Bild-Upload', + ); + } + return _noteFromJson(note); + } on DioException catch (e) { + throw TourRepositoryException(_describe(e, 'Bild hochladen'), e); + } + } + + @override + Future setDeliveryCredit({ + required String deliveryId, + required String clientEventId, + required int amountCents, + required String reason, + String? actorCarId, + }) async { + try { + final request = api.DeliveryCreditEventRequest((b) { + b + ..clientEventId = clientEventId + ..action = api.CreditAction.set_ + ..amountCents = amountCents + ..reason = reason; + if (actorCarId != null) b.authorCarId = actorCarId; + }); + final response = await _api.getDeliveriesApi().applyCredit( + deliveryId: deliveryId, + deliveryCreditEventRequest: request, + ); + return response.data?.credit?.toDomain(); + } on DioException catch (e) { + throw TourRepositoryException(_describe(e, 'Gutschrift setzen'), e); + } + } + + @override + Future removeDeliveryCredit({ + required String deliveryId, + required String clientEventId, + String? actorCarId, + }) async { + try { + final request = api.DeliveryCreditEventRequest((b) { + b + ..clientEventId = clientEventId + ..action = api.CreditAction.remove; + if (actorCarId != null) b.authorCarId = actorCarId; + }); + final response = await _api.getDeliveriesApi().applyCredit( + deliveryId: deliveryId, + deliveryCreditEventRequest: request, + ); + return response.data?.credit?.toDomain(); + } on DioException catch (e) { + throw TourRepositoryException(_describe(e, 'Gutschrift entfernen'), e); + } + } + + @override + Future setDeliveryService({ + required String deliveryId, + required String serviceId, + bool? boolValue, + int? numericValue, + String? actorCarId, + }) async { + try { + final request = api.SetDeliveryServiceRequest((b) { + if (boolValue != null) b.boolValue = boolValue; + if (numericValue != null) b.numericValue = numericValue; + if (actorCarId != null) b.authorCarId = actorCarId; + }); + final response = await _api.getDeliveriesApi().setService( + deliveryId: deliveryId, + serviceId: serviceId, + setDeliveryServiceRequest: request, + ); + final value = response.data?.value; + if (value == null) { + throw const TourRepositoryException( + 'Server lieferte leeren Service-Wert', + ); + } + return value.toDomain(); + } on DioException catch (e) { + throw TourRepositoryException(_describe(e, 'Service setzen'), e); + } + } + + @override + Future removeDeliveryService({ + required String deliveryId, + required String serviceId, + }) async { + try { + await _api.getDeliveriesApi().deleteServiceValue( + deliveryId: deliveryId, + serviceId: serviceId, + ); + } on DioException catch (e) { + throw TourRepositoryException(_describe(e, 'Service entfernen'), e); + } + } + + /// Mappt das rohe Note-JSON (camelCase) der Upload-Antwort in die Domain. + /// Eigene Mini-Deserialisierung, weil dieser Pfad nicht über den + /// generierten Client (mit built_value) läuft. + /// Baut aus der rohen JSON-Map (Stamm-Endpoint-Response **ohne** Items) + /// eine Domain-Delivery. Wie [_liftDeliveryStub], aber für die direkten + /// Dio-Calls (multipart), die keinen typisierten Body liefern. Aufrufer + /// merged Items/sortOrder aus dem lokalen Aggregat. + Delivery _deliveryStubFromJson(Map j) { + final snap = j['deliveryAddressSnapshot'] as Map; + return Delivery( + id: j['id'] as String, + tourId: j['tourId'] as String, + customerId: j['customerId'] as String, + contactPersonIds: + (j['contactPersonIds'] as List).cast().toList(growable: false), + deliveryAddressSnapshot: Address( + street: snap['street'] as String, + houseNumber: snap['houseNumber'] as String, + postalCode: snap['postalCode'] as String, + city: snap['city'] as String, + country: snap['country'] as String, + ), + erpBelegartId: (j['erpBelegartId'] as num).toInt(), + erpBelegnummer: j['erpBelegnummer'] as String, + state: _deliveryStateFromWire(j['state'] as String), + stateReason: j['stateReason'] as String?, + sortOrder: 0, + assignedCarId: j['assignedCarId'] as String?, + desiredTime: j['desiredTime'] as String?, + specialAgreements: j['specialAgreements'] as String?, + items: const [], + prepaidAmount: (j['prepaidAmount'] as num).toDouble(), + paymentMethodId: j['paymentMethodId'] as String, + ); + } + + DeliveryState _deliveryStateFromWire(String value) { + switch (value) { + case 'active': + return DeliveryState.active; + case 'held': + return DeliveryState.held; + case 'canceled': + return DeliveryState.canceled; + case 'completed': + return DeliveryState.completed; + default: + throw TourRepositoryException('Unbekannter DeliveryState: $value'); + } + } + + DeliveryNote _noteFromJson(Map j) => DeliveryNote( + id: j['id'] as String, + deliveryId: j['deliveryId'] as String, + text: j['text'] as String?, + imageAttachment: j['imageAttachment'] as String?, + authorPersonalnummer: (j['authorPersonalnummer'] as num).toInt(), + authorCarId: j['authorCarId'] as String?, + creditDeliveryItemId: j['creditDeliveryItemId'] as String?, + isAmountCreditNote: (j['isAmountCreditNote'] as bool?) ?? false, + createdAt: DateTime.parse(j['createdAt'] as String), + ); + + @override + Future> applyScans(List intents) async { + if (intents.isEmpty) return const {}; + try { + final request = api.ApplyScansRequest((b) { + b.scans.replace(intents.map((i) => i.toWire())); + }); + final response = await _api.getScansApi().applyScans( + applyScansRequest: request, + ); + final results = response.data?.results; + if (results == null) { + throw const TourRepositoryException( + 'Server lieferte leere Scan-Antwort', + ); + } + return {for (final r in results) r.clientScanId: r.toDomain()}; + } on DioException catch (e) { + throw TourRepositoryException(_describe(e, 'Scans anwenden'), e); + } + } + + String _describe(DioException e, String operation) { + final status = e.response?.statusCode; + final body = e.response?.data; + if (status == 400 && body is Map && body['message'] != null) { + return '$operation fehlgeschlagen: ${body['message']}'; + } + if (status == 401) return 'Sitzung abgelaufen'; + if (status == 403) return 'Keine Berechtigung'; + if (status == 404) return 'Tour oder Lieferung nicht gefunden'; + return '$operation fehlgeschlagen (HTTP ${status ?? 'unbekannt'})'; + } +} diff --git a/lib/domain/entity/address.dart b/lib/domain/entity/address.dart new file mode 100644 index 0000000..6afeff4 --- /dev/null +++ b/lib/domain/entity/address.dart @@ -0,0 +1,55 @@ +/// Postanschrift — Value-Object, identitätslos. +/// +/// Tritt im Domain an drei Stellen auf: am `Customer` (Stamm-Adresse) und +/// als `deliveryAddressSnapshot` auf der `Delivery` (eingefrorene Kopie der +/// Adresse zum Zeitpunkt der Belegerzeugung, damit nachträgliche Änderungen +/// am Stammdatensatz die ausgelieferte Tour nicht „verschieben"). Spiegelt +/// das Backend-DTO `Address` 1:1. +class Address { + const Address({ + required this.street, + required this.houseNumber, + required this.postalCode, + required this.city, + required this.country, + }); + + final String street; + final String houseNumber; + final String postalCode; + final String city; + final String country; + + /// Einzeilige Darstellung für Listen/Header. + String get oneLine => + '$street $houseNumber, $postalCode $city'; + + Address copyWith({ + String? street, + String? houseNumber, + String? postalCode, + String? city, + String? country, + }) { + return Address( + street: street ?? this.street, + houseNumber: houseNumber ?? this.houseNumber, + postalCode: postalCode ?? this.postalCode, + city: city ?? this.city, + country: country ?? this.country, + ); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is Address && + other.street == street && + other.houseNumber == houseNumber && + other.postalCode == postalCode && + other.city == city && + other.country == country; + + @override + int get hashCode => Object.hash(street, houseNumber, postalCode, city, country); +} diff --git a/lib/domain/entity/article.dart b/lib/domain/entity/article.dart new file mode 100644 index 0000000..20a5042 --- /dev/null +++ b/lib/domain/entity/article.dart @@ -0,0 +1,45 @@ +/// Stammdatensatz für einen Artikel (Ware). +/// +/// Der Domain-Artikel kennt — anders als das alte ERPframe-Modell — keine +/// Eltern-Kind-Beziehungen mehr. Stücklisten (BOM/Komponenten) werden im +/// neuen Backend als gleichrangige `DeliveryItem`s mit gesetztem +/// `komponentenArtikelNr` modelliert; der Treiber scannt einfach jedes Item +/// separat. Hier deshalb absichtlich kein `components`/`parent`-Feld. +class Article { + const Article({ + required this.id, + required this.articleNumber, + required this.name, + required this.scannable, + this.defaultWarehouseId, + }); + + final String id; + final String articleNumber; + final String name; + + /// Nicht-scanbar = wird nicht über den Scanner durchgereicht (z. B. + /// Dienstleistung, Versandkosten). In der Loading-Phase ausgeblendet. + final bool scannable; + + /// Lager-Default für diesen Artikel; das tatsächlich relevante Lager pro + /// Lieferung steht aber am `DeliveryItem.warehouseId`. Wird nur als + /// UX-Hinweis verwendet. + final String? defaultWarehouseId; + + Article copyWith({ + String? id, + String? articleNumber, + String? name, + bool? scannable, + String? defaultWarehouseId, + }) { + return Article( + id: id ?? this.id, + articleNumber: articleNumber ?? this.articleNumber, + name: name ?? this.name, + scannable: scannable ?? this.scannable, + defaultWarehouseId: defaultWarehouseId ?? this.defaultWarehouseId, + ); + } +} diff --git a/lib/domain/entity/contact_source.dart b/lib/domain/entity/contact_source.dart new file mode 100644 index 0000000..aaaa312 --- /dev/null +++ b/lib/domain/entity/contact_source.dart @@ -0,0 +1,226 @@ +/// Adress-Rolle eines Beleg-Kontakts. Spiegelt die fünf Adress-FKs am +/// ERP-`Belegkopf` (bzw. den Umweg über `Kunden.AdressId`). Die App nutzt +/// das primär als Gruppierungs-Label in der Detail-Ansicht. +enum ContactRole { + /// `Belegkopf.AdressId` — die „eigentliche" Belegadresse. + header, + + /// `Belegkopf.LieferAdressId` — kann von der Belegadresse abweichen. + delivery, + + /// `Belegkopf.RechnungsAdressId`. + billing, + + /// `Belegkopf.AnsprechpartnerId` — verlinkt eine Person, nicht eine Firma. + contactPerson, + + /// `Kunden.AdressId` (über `Belegkopf.KundenId`). Die Stammadresse des + /// Kunden — dient als Fallback, wenn die belegspezifischen Adressen leer + /// sind. + customerMaster; + + /// Wire-Repräsentation aus dem Backend (serde `snake_case`). + static ContactRole fromWire(String value) { + switch (value) { + case 'header': + return ContactRole.header; + case 'delivery': + return ContactRole.delivery; + case 'billing': + return ContactRole.billing; + case 'contact_person': + return ContactRole.contactPerson; + case 'customer_master': + return ContactRole.customerMaster; + default: + throw StateError('Unbekannte ContactRole vom Backend: $value'); + } + } + + /// Deutscher Label-Text für die UI. + String get label { + switch (this) { + case ContactRole.header: + return 'Belegadresse'; + case ContactRole.delivery: + return 'Lieferadresse'; + case ContactRole.billing: + return 'Rechnungsadresse'; + case ContactRole.contactPerson: + return 'Ansprechpartner'; + case ContactRole.customerMaster: + return 'Kundenstamm'; + } + } +} + +/// Art eines Kommunikationskanals. `fax` wird vom Backend bewusst nicht +/// mitgeführt — die App braucht es nicht. +enum ContactKind { + phone, + mobile, + email, + web; + + static ContactKind fromWire(String value) { + switch (value) { + case 'phone': + return ContactKind.phone; + case 'mobile': + return ContactKind.mobile; + case 'email': + return ContactKind.email; + case 'web': + return ContactKind.web; + default: + throw StateError('Unbekannter ContactKind vom Backend: $value'); + } + } +} + +/// Eine Adress-Quelle, die am Beleg hängt — z. B. Lieferadresse oder +/// Ansprechpartner. Der Namensblock kommt direkt aus ERP-`Adressen` +/// (`Anrede`/`Titel`/`Name1..3`/`Abteilung`/`Funktion`); die eigentlichen +/// Telefonnummern, E-Mails etc. liegen verteilt in zugehörigen +/// [ContactChannel]s und werden in [TourDetails.channelsOf] zusammengeführt. +class ContactSource { + const ContactSource({ + required this.id, + required this.deliveryId, + required this.role, + this.anrede, + this.titel, + this.name1, + this.name2, + this.name3, + this.abteilung, + this.funktion, + }); + + final String id; + final String deliveryId; + final ContactRole role; + + final String? anrede; + final String? titel; + final String? name1; + final String? name2; + final String? name3; + final String? abteilung; + final String? funktion; + + /// Zusammengesetzte Anzeige des Namens — Anrede + Titel + Name1..3 in + /// dieser Reihenfolge, leere Felder werden übersprungen. Gibt `null` + /// zurück, wenn die Quelle gar keinen Namen trägt (kann vorkommen, wenn + /// nur Telefonnummern hinterlegt sind). + String? get displayName { + final parts = [ + if (anrede != null && anrede!.isNotEmpty) anrede!, + if (titel != null && titel!.isNotEmpty) titel!, + if (name1 != null && name1!.isNotEmpty) name1!, + if (name2 != null && name2!.isNotEmpty) name2!, + if (name3 != null && name3!.isNotEmpty) name3!, + ]; + if (parts.isEmpty) return null; + return parts.join(' '); + } + + /// Funktionale Zusatzinfo (z. B. „Buchhaltung · Leitung"). Leere + /// Komponenten werden ausgeblendet. + String? get subtitle { + final parts = [ + if (abteilung != null && abteilung!.isNotEmpty) abteilung!, + if (funktion != null && funktion!.isNotEmpty) funktion!, + ]; + if (parts.isEmpty) return null; + return parts.join(' · '); + } +} + +/// Ein einzelner Kommunikationskanal (Telefon, Mobil, E-Mail, Web). Mehrere +/// pro [ContactSource] möglich; die [position] (1-basiert) erhält die +/// ERP-Reihenfolge — Position 1 ist der primäre Kanal, Position 2 das +/// erste Zusatzfeld usw. +class ContactChannel { + const ContactChannel({ + required this.id, + required this.sourceId, + required this.kind, + required this.position, + required this.value, + }); + + final String id; + final String sourceId; + final ContactKind kind; + final int position; + final String value; +} + +/// Zusammengeführte Sicht auf 1..n [ContactSource]s, die fachlich denselben +/// Kontakt darstellen — gleicher Namensblock UND gleiche Channel-Liste +/// (also exakt dieselbe Adresse im ERP, nur über verschiedene FKs am +/// `Belegkopf` referenziert: typischerweise `AdressId` und `Kunden.AdressId`, +/// die in den allermeisten Belegen identisch sind). +/// +/// Die App rendert pro Lieferung eine Karte je Eintrag; `roles` listet +/// alle Rollen auf, die zu diesem Eintrag beitragen (z. B. „Belegadresse · +/// Kundenstamm"). Die Channels werden 1:1 von der ersten Quelle übernommen +/// — alle Quellen in einer Gruppe haben dieselben. +class MergedContactSource { + const MergedContactSource({ + required this.roles, + required this.anrede, + required this.titel, + required this.name1, + required this.name2, + required this.name3, + required this.abteilung, + required this.funktion, + required this.channels, + }); + + /// Alle Rollen, die diesen zusammengeführten Kontakt liefern. + /// Reihenfolge wie in der enum-Definition: header → delivery → billing + /// → contactPerson → customerMaster, damit das Label stabil bleibt. + final List roles; + + final String? anrede; + final String? titel; + final String? name1; + final String? name2; + final String? name3; + final String? abteilung; + final String? funktion; + + /// Channels in der gleichen Reihenfolge, wie das Backend sie pro Quelle + /// liefert (kind + ERP-Position). + final List channels; + + /// Zusammengesetzter Anzeigename — identisch zu [ContactSource.displayName]. + String? get displayName { + final parts = [ + if (anrede != null && anrede!.isNotEmpty) anrede!, + if (titel != null && titel!.isNotEmpty) titel!, + if (name1 != null && name1!.isNotEmpty) name1!, + if (name2 != null && name2!.isNotEmpty) name2!, + if (name3 != null && name3!.isNotEmpty) name3!, + ]; + if (parts.isEmpty) return null; + return parts.join(' '); + } + + String? get subtitle { + final parts = [ + if (abteilung != null && abteilung!.isNotEmpty) abteilung!, + if (funktion != null && funktion!.isNotEmpty) funktion!, + ]; + if (parts.isEmpty) return null; + return parts.join(' · '); + } + + /// Header-Label für die UI — alle Rollen mit `·` getrennt, in + /// Enum-Reihenfolge. + String get rolesLabel => roles.map((r) => r.label).join(' · '); +} + diff --git a/lib/domain/entity/customer.dart b/lib/domain/entity/customer.dart new file mode 100644 index 0000000..af5aaac --- /dev/null +++ b/lib/domain/entity/customer.dart @@ -0,0 +1,53 @@ +import 'address.dart'; + +/// Kunden-Stammdatensatz. Ein Kunde kann mehrere `CustomerContact`s haben +/// (Ehepartner, Hausverwalter, …); diese werden separat in der +/// `TourDetails.contacts`-Map geführt. +class Customer { + const Customer({ + required this.id, + required this.name, + required this.erpCustomerId, + required this.address, + }); + + final String id; + final String name; + + /// ERP-Kundennummer (Legacy). Wird in der App nur informativ in der + /// Detail-Ansicht angezeigt. + final int erpCustomerId; + final Address address; + + Customer copyWith({ + String? id, + String? name, + int? erpCustomerId, + Address? address, + }) { + return Customer( + id: id ?? this.id, + name: name ?? this.name, + erpCustomerId: erpCustomerId ?? this.erpCustomerId, + address: address ?? this.address, + ); + } +} + +/// Ansprechpartner zu einem Kunden. Optional, daher als eigene Liste in +/// `TourDetails` — eine Lieferung referenziert n Kontakte per Id. +class CustomerContact { + const CustomerContact({ + required this.id, + required this.customerId, + required this.name, + this.phone, + this.email, + }); + + final String id; + final String customerId; + final String name; + final String? phone; + final String? email; +} diff --git a/lib/domain/entity/delivery.dart b/lib/domain/entity/delivery.dart new file mode 100644 index 0000000..d4819d5 --- /dev/null +++ b/lib/domain/entity/delivery.dart @@ -0,0 +1,152 @@ +import 'address.dart'; +import 'delivery_item.dart'; + +/// Lebenszyklus einer Lieferung. +/// +/// - `active`: Standard nach Anlage; Fahrer kann scannen/ausliefern. +/// - `held`: Pausiert (Kunde nicht da, Termin verschoben) — kein Bearbeitungsfortschritt. +/// - `canceled`: Abgebrochen — wird nicht mehr ausgeliefert. +/// - `completed`: Abgeschlossen — Signatur und Notizen sind hinterlegt. +enum DeliveryState { active, held, canceled, completed } + +/// Eine einzelne Auslieferung an einen Kunden innerhalb einer Tour. +/// +/// Anders als im alten Modell trägt `Delivery` hier ausschließlich +/// Logistik-Daten — keine Preise, keine Rabatte, keine Zahlungsoptionen. +/// Diese ERP-Themen sind in Phase C+D-2 absichtlich nicht migriert und +/// hängen hinter `FeatureFlags`. +class Delivery { + const Delivery({ + required this.id, + required this.tourId, + required this.customerId, + required this.contactPersonIds, + required this.deliveryAddressSnapshot, + required this.erpBelegartId, + required this.erpBelegnummer, + required this.state, + required this.sortOrder, + required this.items, + required this.prepaidAmount, + required this.paymentMethodId, + this.assignedCarId, + this.desiredTime, + this.specialAgreements, + this.stateReason, + }); + + final String id; + final String tourId; + final String customerId; + + /// 0..n Kontakte am Kunden, die für diese Lieferung relevant sind. + /// Lookup über `TourDetails.contactById`. + final List contactPersonIds; + + /// Eingefrorene Lieferadresse zum Zeitpunkt der Belegerzeugung — bleibt + /// stabil, auch wenn die Stammadresse am Kunden später geändert wird. + final Address deliveryAddressSnapshot; + + /// ERP-Belegart (Lieferschein, Rechnung, …) und -Nummer. Für die App nur + /// informativ; in Notizen/Reklamationen ist die Belegnummer der vom + /// Kunden verständliche Bezugspunkt. + final int erpBelegartId; + final String erpBelegnummer; + + final DeliveryState state; + + /// Optionaler Klartext, warum `state` auf `held`/`canceled` steht. Vom + /// Backend nicht-leer erzwungen, sobald ein Reason-pflichtiger Zustand + /// gesetzt wird. + final String? stateReason; + + /// Sortier-Reihenfolge innerhalb der Tour, gesetzt durch + /// `PUT /tours/{id}/delivery-order`. Niedriger = früher. + final int sortOrder; + + /// UUID des Fahrzeugs, dem diese Lieferung beim Laden zugewiesen wurde. + /// `null` = noch nicht zugewiesen. + final String? assignedCarId; + + /// Bei Bestellung schon bezahlter Betrag in EUR. `0.0` wenn der Kunde + /// alles bei Lieferung zahlt. Wird vom ERP-Sync gesetzt. + final double prepaidAmount; + + /// FK auf eine `PaymentMethod` (UUID). Auflösung zu Display-Name und + /// Aktiv-Status geht über die Stammdaten-Liste, die die App separat + /// lädt — nicht hier embeddet, damit das Tour-Aggregat klein bleibt. + final String paymentMethodId; + + final String? desiredTime; + final String? specialAgreements; + + final List items; + + // ─── Abgeleitete Sicht-Eigenschaften ────────────────────────────────── + + /// Nur Items, die der Treiber tatsächlich scannen muss. Nicht-scanbare + /// Artikel (Dienstleistungen, Versand) sowie bereits entfernte Items + /// werden nicht mitgezählt. + Iterable scannableItems( + bool Function(String articleId) isScannable, + ) sync* { + for (final item in items) { + if (item.isRemoved) continue; + if (!isScannable(item.articleId)) continue; + yield item; + } + } + + /// `true`, sobald *alle* scanbaren Items dieser Lieferung als `done` + /// markiert sind. Wird in der Loading-Übersicht angezeigt und + /// kontrolliert in der Detail-Phase den Übergang zur Signatur. + bool allScannableItemsDone(bool Function(String articleId) isScannable) { + final scannables = scannableItems(isScannable).toList(); + if (scannables.isEmpty) return false; + return scannables.every((item) => item.isDone); + } + + Delivery copyWith({ + String? id, + String? tourId, + String? customerId, + List? contactPersonIds, + Address? deliveryAddressSnapshot, + int? erpBelegartId, + String? erpBelegnummer, + DeliveryState? state, + String? stateReason, + int? sortOrder, + String? assignedCarId, + Object? desiredTime = _sentinel, + Object? specialAgreements = _sentinel, + List? items, + double? prepaidAmount, + String? paymentMethodId, + }) { + return Delivery( + id: id ?? this.id, + tourId: tourId ?? this.tourId, + customerId: customerId ?? this.customerId, + contactPersonIds: contactPersonIds ?? this.contactPersonIds, + deliveryAddressSnapshot: deliveryAddressSnapshot ?? this.deliveryAddressSnapshot, + erpBelegartId: erpBelegartId ?? this.erpBelegartId, + erpBelegnummer: erpBelegnummer ?? this.erpBelegnummer, + state: state ?? this.state, + stateReason: stateReason ?? this.stateReason, + sortOrder: sortOrder ?? this.sortOrder, + assignedCarId: assignedCarId ?? this.assignedCarId, + desiredTime: identical(desiredTime, _sentinel) + ? this.desiredTime + : desiredTime as String?, + specialAgreements: identical(specialAgreements, _sentinel) + ? this.specialAgreements + : specialAgreements as String?, + items: items ?? this.items, + prepaidAmount: prepaidAmount ?? this.prepaidAmount, + paymentMethodId: paymentMethodId ?? this.paymentMethodId, + ); + } +} + +const Object _sentinel = Object(); diff --git a/lib/domain/entity/delivery_credit.dart b/lib/domain/entity/delivery_credit.dart new file mode 100644 index 0000000..33fff90 --- /dev/null +++ b/lib/domain/entity/delivery_credit.dart @@ -0,0 +1,20 @@ +/// Aktuelle Betrags-Gutschrift einer Lieferung (Geld-Nachlass, unabhängig von +/// Stückzahl). Server-seitig aus dem append-only `delivery_credit_audit` +/// abgeleitet (jüngstes Ereignis); existiert nur, solange der letzte Stand +/// `set` ist. +class DeliveryCredit { + const DeliveryCredit({ + required this.deliveryId, + required this.amountCents, + required this.reason, + }); + + final String deliveryId; + + /// Betrag in Cent (> 0, ≤ 15000). + final int amountCents; + final String reason; + + /// Betrag in ganzen Euro (die Gutschrift läuft in 10-€-Schritten). + int get amountEuros => (amountCents / 100).round(); +} diff --git a/lib/domain/entity/delivery_item.dart b/lib/domain/entity/delivery_item.dart new file mode 100644 index 0000000..1d70ce2 --- /dev/null +++ b/lib/domain/entity/delivery_item.dart @@ -0,0 +1,106 @@ +import 'scan_progress.dart'; + +/// Eine Belegzeile innerhalb einer Lieferung. +/// +/// Verweist über `articleId` auf den Artikel-Stamm (lookup via +/// `TourDetails.articleById`) und über `warehouseId` auf das Lager. Die +/// Soll-/Ist-Quantitäten leben hier: `requiredQuantity` ist statisch (ERP), +/// `scanProgress.scannedQuantity` wandert mit jedem Scan nach oben. +/// +/// `komponentenArtikelNr` markiert Stücklisten-Komponenten. Im neuen +/// Backend gibt es **keine** Parent-/Child-Hierarchie mehr — jedes Item ist +/// gleichrangig; das Feld dient nur noch der Anzeige ("Teil von X") und +/// hat keinerlei Scan-Semantik. +class DeliveryItem { + const DeliveryItem({ + required this.id, + required this.deliveryId, + required this.articleId, + required this.warehouseId, + required this.belegzeilenNr, + required this.requiredQuantity, + required this.scanProgress, + this.unitPrice = 0, + this.komponentenArtikelNr, + this.parentArtikelNr, + }); + + final String id; + final String deliveryId; + final String articleId; + final String warehouseId; + + /// ERP-Belegzeilen-Nummer. Bestimmt die Reihenfolge der Items in der + /// Detail-Ansicht (aufsteigend). + final int belegzeilenNr; + final int requiredQuantity; + final ScanProgress scanProgress; + + /// Stückpreis (brutto, EUR) aus dem ERP-Sync. + final double unitPrice; + + final String? komponentenArtikelNr; + + /// Artikelnummer des Oberartikels, zu dem diese Komponente gehört (aus dem + /// Sync). `null` bei Oberartikeln/regulären Zeilen. Die Liste rückt + /// Komponenten unter ihrem Oberartikel ein. + final String? parentArtikelNr; + + /// `true`, wenn dieses Item eine Stücklisten-Komponente ist (gehört unter + /// einen Oberartikel). + bool get isComponent => parentArtikelNr != null; + + // ─── Abgeleitete Sicht-Eigenschaften ────────────────────────────────── + + /// Tatsächlich auszuliefernde Menge = Soll − Gutschrift. Nie negativ. + int get deliveredQuantity { + final d = requiredQuantity - scanProgress.creditedQuantity; + return d < 0 ? 0 : d; + } + + /// Wert der ausgelieferten Menge dieser Position (brutto, EUR). + double get lineTotal => unitPrice * deliveredQuantity; + + /// Vollständig gescannt (Status `done` oder Ist ≥ Soll). + bool get isDone => + scanProgress.status == ScanStatus.done || + scanProgress.scannedQuantity >= requiredQuantity; + + /// Aktuell pausiert. + bool get isHeld => scanProgress.status == ScanStatus.held; + + /// Nach dem Laden wieder entfernt. + bool get isRemoved => scanProgress.status == ScanStatus.removed; + + /// Noch offene Restmenge (für Loading-UI). Nicht negativ. + int get remainingQuantity { + final remaining = requiredQuantity - scanProgress.scannedQuantity; + return remaining < 0 ? 0 : remaining; + } + + DeliveryItem copyWith({ + String? id, + String? deliveryId, + String? articleId, + String? warehouseId, + int? belegzeilenNr, + int? requiredQuantity, + ScanProgress? scanProgress, + double? unitPrice, + String? komponentenArtikelNr, + String? parentArtikelNr, + }) { + return DeliveryItem( + id: id ?? this.id, + deliveryId: deliveryId ?? this.deliveryId, + articleId: articleId ?? this.articleId, + warehouseId: warehouseId ?? this.warehouseId, + belegzeilenNr: belegzeilenNr ?? this.belegzeilenNr, + requiredQuantity: requiredQuantity ?? this.requiredQuantity, + scanProgress: scanProgress ?? this.scanProgress, + unitPrice: unitPrice ?? this.unitPrice, + komponentenArtikelNr: komponentenArtikelNr ?? this.komponentenArtikelNr, + parentArtikelNr: parentArtikelNr ?? this.parentArtikelNr, + ); + } +} diff --git a/lib/domain/entity/delivery_note.dart b/lib/domain/entity/delivery_note.dart new file mode 100644 index 0000000..ed304f0 --- /dev/null +++ b/lib/domain/entity/delivery_note.dart @@ -0,0 +1,83 @@ +/// Notiz an einer Lieferung. Text und/oder Bildanhang können gesetzt sein — +/// das Backend erzwingt nicht-leer für mindestens einen der beiden. +/// +/// `imageAttachment` ist die UUID des hinterlegten Bildes; das eigentliche +/// Binary wird über einen separaten Endpoint geladen (in einer späteren +/// Phase modelliert). +class DeliveryNote { + const DeliveryNote({ + required this.id, + required this.deliveryId, + required this.authorPersonalnummer, + required this.createdAt, + this.text, + this.imageAttachment, + this.authorCarId, + this.creditDeliveryItemId, + this.isAmountCreditNote = false, + this.imageAttachmentDeleted = false, + }); + + final String id; + final String deliveryId; + final String? text; + final String? imageAttachment; + + /// Personalnummer des Fahrers (aus dem JWT zum Zeitpunkt der Erstellung). + /// `int` weil im JWT als numerischer Claim transportiert. + final int authorPersonalnummer; + + /// Fahrzeug, mit dem die Notiz erstellt wurde (Audit-Spur, optional). + final String? authorCarId; + + /// Gesetzt, wenn die Notiz als Gutschrift-Grund zu einer Belegzeile + /// angelegt wurde (deren `DeliveryItem`-Id). Erlaubt es, die Notiz beim + /// Zurücknehmen der Gutschrift (Unremove) gezielt wieder zu löschen. + final String? creditDeliveryItemId; + + /// `true`, wenn die Notiz den Grund einer Betrags-Gutschrift dokumentiert + /// (Lieferungs-Ebene). Wird beim Entfernen der Gutschrift gezielt gelöscht. + final bool isAmountCreditNote; + + /// `true`, wenn die lokale Bilddatei nach erfolgreichem Report-Upload + /// gelöscht wurde — das Bild steckt dann im Lieferbericht (DOCUframe). + /// Die UI zeigt statt der Vorschau einen Hinweis. + final bool imageAttachmentDeleted; + + final DateTime createdAt; + + DeliveryNote copyWith({ + String? id, + String? deliveryId, + Object? text = _sentinel, + Object? imageAttachment = _sentinel, + int? authorPersonalnummer, + Object? authorCarId = _sentinel, + Object? creditDeliveryItemId = _sentinel, + bool? isAmountCreditNote, + bool? imageAttachmentDeleted, + DateTime? createdAt, + }) { + return DeliveryNote( + id: id ?? this.id, + deliveryId: deliveryId ?? this.deliveryId, + text: identical(text, _sentinel) ? this.text : text as String?, + imageAttachment: identical(imageAttachment, _sentinel) + ? this.imageAttachment + : imageAttachment as String?, + authorPersonalnummer: authorPersonalnummer ?? this.authorPersonalnummer, + authorCarId: identical(authorCarId, _sentinel) + ? this.authorCarId + : authorCarId as String?, + creditDeliveryItemId: identical(creditDeliveryItemId, _sentinel) + ? this.creditDeliveryItemId + : creditDeliveryItemId as String?, + isAmountCreditNote: isAmountCreditNote ?? this.isAmountCreditNote, + imageAttachmentDeleted: + imageAttachmentDeleted ?? this.imageAttachmentDeleted, + createdAt: createdAt ?? this.createdAt, + ); + } +} + +const Object _sentinel = Object(); diff --git a/lib/domain/entity/delivery_service_value.dart b/lib/domain/entity/delivery_service_value.dart new file mode 100644 index 0000000..a445c5d --- /dev/null +++ b/lib/domain/entity/delivery_service_value.dart @@ -0,0 +1,15 @@ +/// Pro-Lieferung gesetzter Wert eines Service. Je nach Service-Typ ist genau +/// einer der beiden Slots gefüllt. +class DeliveryServiceValue { + const DeliveryServiceValue({ + required this.deliveryId, + required this.serviceId, + this.boolValue, + this.numericValue, + }); + + final String deliveryId; + final String serviceId; + final bool? boolValue; + final int? numericValue; +} diff --git a/lib/domain/entity/payment_method.dart b/lib/domain/entity/payment_method.dart new file mode 100644 index 0000000..8c2890a --- /dev/null +++ b/lib/domain/entity/payment_method.dart @@ -0,0 +1,38 @@ +/// Zahlungs-Stammdatensatz — spiegelt das Backend-Aggregat `PaymentMethod`. +/// +/// `code` ist der stabile Programm-Identifier (z. B. `"cash"`, +/// `"invoice"`); UI-Code kann darüber spezielle Methoden referenzieren, +/// ohne die UUID kennen zu müssen. `active = false` ist Soft-Delete — +/// die Methode bleibt für historische Lieferungen referenzierbar, +/// taucht aber in der Auswahl bei neuen Lieferungen nicht mehr auf. +class PaymentMethod { + const PaymentMethod({ + required this.id, + required this.code, + required this.name, + required this.active, + required this.createdAt, + }); + + final String id; + final String code; + final String name; + final bool active; + final DateTime createdAt; + + PaymentMethod copyWith({ + String? id, + String? code, + String? name, + bool? active, + DateTime? createdAt, + }) { + return PaymentMethod( + id: id ?? this.id, + code: code ?? this.code, + name: name ?? this.name, + active: active ?? this.active, + createdAt: createdAt ?? this.createdAt, + ); + } +} diff --git a/lib/domain/entity/scan_intent.dart b/lib/domain/entity/scan_intent.dart new file mode 100644 index 0000000..7626a81 --- /dev/null +++ b/lib/domain/entity/scan_intent.dart @@ -0,0 +1,93 @@ +/// Vom Treiber ausgelöstes Scan-Ereignis, bevor es serverseitig +/// angewendet wurde. +/// +/// `clientScanId` ist ein vom Client generierter UUID-Schlüssel und dient +/// als **Idempotenz-Anker**: der Server speichert ihn beim ersten Apply +/// und antwortet auf jeden weiteren Request mit derselben Id mit +/// `duplicate` statt einer zweiten Anwendung. So bleibt Network-Retry +/// (z. B. nach Verbindungsabbruch beim ersten POST) bedeutungslos. +/// +/// `clientScannedAt` ist die Wall-Clock-Zeit am Gerät zum Zeitpunkt des +/// Scans — der Server nutzt das nur als Audit-Spur, sortiert aber selbst +/// nach Server-Empfangszeit, sodass eine schiefe Uhr am Phone die +/// Reihenfolge nicht durcheinanderbringt. +class ScanIntent { + const ScanIntent({ + required this.clientScanId, + required this.clientScannedAt, + required this.deliveryItemId, + required this.action, + this.actorCarId, + this.reason, + this.quantity, + this.manual = false, + }); + + final String clientScanId; + final DateTime clientScannedAt; + final String deliveryItemId; + final ScanAction action; + + /// `true`, wenn der Fahrer die Position manuell als geladen bestätigt hat + /// (Fallback ohne Barcode). Reine Audit-Information; Default `false`. + final bool manual; + + /// Menge für `remove` / `unremove` (Mengen-Gutschrift): wie viele Stück + /// der Belegzeile gutgeschrieben bzw. wiederhergestellt werden. `null` = + /// ganze Restmenge. Bei `scan`/`unscan`/`hold`/`unhold` ignoriert. + final int? quantity; + + /// Fahrzeug, mit dem gescannt wurde — Audit-Spur. Optional, aber die + /// App schickt ihn in der Loading-Phase immer mit, weil das Auto zu + /// dem Zeitpunkt definitiv gewählt ist. + final String? actorCarId; + + /// Klartext-Begründung. Bei `unscan` / `hold` / `remove` vom Backend + /// erwartet, bei `scan` / `unhold` ignoriert. + final String? reason; +} + +/// Auswirkung eines Scan-Ereignisses auf die Pipeline eines Items. +/// Spiegel des Backend-Enums `AuditAction`. +/// +/// `unremove` ist die Umkehrung von `remove`: setzt ein `Removed`-Item +/// zurück auf `InProgress` (oder `Done`, falls die Soll-Menge schon +/// erreicht war). Der ursprüngliche `remove`-Audit-Eintrag bleibt +/// erhalten — `unremove` erzeugt einen eigenen Eintrag, sodass die +/// Historie der Korrektur vollständig nachvollziehbar bleibt. +enum ScanAction { scan, unscan, hold, unhold, remove, unremove } + +/// Ergebnis eines Apply-Versuchs vom Server. +class ScanOutcome { + const ScanOutcome({ + required this.clientScanId, + required this.status, + this.deliveryItemId, + this.reason, + }); + + final String clientScanId; + final ScanOutcomeStatus status; + + /// Bei `applied` und `duplicate` immer gesetzt, bei `rejected` häufig + /// `null` (z. B. wenn die Id beim Server gar nicht ankam). + final String? deliveryItemId; + + /// Bei `rejected` die Server-Begründung — Standard-Text in der UI. + final String? reason; +} + +enum ScanOutcomeStatus { + /// Server hat den Scan angewendet — `scannedQuantity` ist hochgezählt + /// oder Status hat sich geändert. + applied, + + /// Server hat denselben `clientScanId` schon einmal verarbeitet — + /// kein Effekt, aber auch kein Fehler. + duplicate, + + /// Server hat den Scan abgelehnt (z. B. Item gehört zu fremder + /// Lieferung, Soll-Menge schon voll, Item ist auf `removed`). UI muss + /// optimistische Mutation zurückrollen. + rejected, +} diff --git a/lib/domain/entity/scan_progress.dart b/lib/domain/entity/scan_progress.dart new file mode 100644 index 0000000..e75dcf2 --- /dev/null +++ b/lib/domain/entity/scan_progress.dart @@ -0,0 +1,48 @@ +/// Status der Scan-Pipeline eines einzelnen `DeliveryItem`. +/// +/// - `inProgress`: Soll-Menge noch nicht erreicht, Scanner darf weiterzählen. +/// - `done`: Soll-Menge erreicht; weitere Scans werden serverseitig abgewiesen. +/// - `held`: Pausiert (z. B. „Ware beschädigt, klärt der Fahrer mit dem Lager") — +/// `ScanProgress.heldReason` trägt die Begründung. +/// - `removed`: Item wurde nach dem Laden wieder abgebucht (Retoure, Falschladung). +enum ScanStatus { inProgress, done, held, removed } + +/// Embedded Value-Object am `DeliveryItem`. Beschreibt, wie weit der Fahrer +/// mit dem Scannen dieses Items ist — *nicht*, wo das Item logistisch steht. +class ScanProgress { + const ScanProgress({ + required this.status, + required this.scannedQuantity, + required this.lastUpdatedAt, + this.creditedQuantity = 0, + this.heldReason, + }); + + final ScanStatus status; + final int scannedQuantity; + + /// Als Gutschrift entfernte Menge (0..=requiredQuantity). Eigene Dimension + /// neben [scannedQuantity]: „wie viele Stück dieser Zeile hat der Kunde + /// nicht angenommen". `status == removed` entspricht voller Gutschrift + /// (creditedQuantity == requiredQuantity). + final int creditedQuantity; + + final DateTime lastUpdatedAt; + final String? heldReason; + + ScanProgress copyWith({ + ScanStatus? status, + int? scannedQuantity, + int? creditedQuantity, + DateTime? lastUpdatedAt, + String? heldReason, + }) { + return ScanProgress( + status: status ?? this.status, + scannedQuantity: scannedQuantity ?? this.scannedQuantity, + creditedQuantity: creditedQuantity ?? this.creditedQuantity, + lastUpdatedAt: lastUpdatedAt ?? this.lastUpdatedAt, + heldReason: heldReason ?? this.heldReason, + ); + } +} diff --git a/lib/domain/entity/service.dart b/lib/domain/entity/service.dart new file mode 100644 index 0000000..2510493 --- /dev/null +++ b/lib/domain/entity/service.dart @@ -0,0 +1,29 @@ +/// Eingabetyp eines Service. `boolean` → Checkbox, `numeric` → Zahlenfeld +/// mit optionalen Grenzen. +enum ServiceKind { boolean, numeric } + +/// Service-Stammdatensatz (früher „Lieferoption") — admin-konfigurierbar. +/// In Phase 4 rendert die App aus den aktiven Services die Auswahl. +class Service { + const Service({ + required this.id, + required this.key, + required this.name, + required this.kind, + required this.active, + required this.sortOrder, + this.minValue, + this.maxValue, + }); + + final String id; + final String key; + final String name; + final ServiceKind kind; + final bool active; + final int sortOrder; + + /// Nur bei [ServiceKind.numeric] relevant. + final int? minValue; + final int? maxValue; +} diff --git a/lib/domain/entity/tour.dart b/lib/domain/entity/tour.dart new file mode 100644 index 0000000..b540914 --- /dev/null +++ b/lib/domain/entity/tour.dart @@ -0,0 +1,53 @@ +/// Aggregat-Wurzel eines Tour-Tages. +/// +/// Die `Tour` selbst ist minimal — sie hält nur Identität und Eckdaten; +/// die fachlich interessanten Daten (Lieferungen + Stammdaten-Lookups) +/// sitzen in `TourDetails`. Diese Trennung erlaubt es, Touren-Listen +/// (z. B. `/me/tours/today`) zu rendern, ohne das gesamte Aggregat +/// laden zu müssen. +class Tour { + const Tour({ + required this.id, + required this.accountId, + required this.date, + required this.syncedAt, + }); + + final String id; + final int accountId; + final DateTime date; + + /// Zeitpunkt des letzten ERP-Sync. Wird in der Header-Zeile als + /// „Stand: …"-Hinweis angezeigt — wenn das ungewöhnlich alt ist, sieht + /// der Fahrer das. + final DateTime syncedAt; + + Tour copyWith({ + String? id, + int? accountId, + DateTime? date, + DateTime? syncedAt, + }) { + return Tour( + id: id ?? this.id, + accountId: accountId ?? this.accountId, + date: date ?? this.date, + syncedAt: syncedAt ?? this.syncedAt, + ); + } +} + +/// Tagestour-Übersicht, wie sie `/me/tours/today` liefert. Schlankes Objekt +/// für die Initialphase (Tour-Auswahl), ohne das volle Aggregat zu +/// transportieren. +class TourSummary { + const TourSummary({ + required this.tourId, + required this.tourDate, + required this.deliveryCount, + }); + + final String tourId; + final DateTime tourDate; + final int deliveryCount; +} diff --git a/lib/domain/entity/tour_details.dart b/lib/domain/entity/tour_details.dart new file mode 100644 index 0000000..737cf24 --- /dev/null +++ b/lib/domain/entity/tour_details.dart @@ -0,0 +1,428 @@ +import 'article.dart'; +import 'contact_source.dart'; +import 'customer.dart'; +import 'delivery.dart'; +import 'delivery_credit.dart'; +import 'delivery_item.dart'; +import 'delivery_note.dart'; +import 'delivery_service_value.dart'; +import 'service.dart'; +import 'tour.dart'; +import 'warehouse.dart'; + +/// Voll geladenes Tour-Aggregat. Enthält die Tour selbst, alle Lieferungen +/// inkl. Items sowie *alle* Stammdaten, die von diesem Schnitt referenziert +/// werden. Die Stammdaten kommen als Lookup-Maps statt als List, damit das +/// UI ohne O(n)-Suchen auskommt. +/// +/// Die Notizen sind im Backend in einer flachen Liste — wir indizieren sie +/// hier einmal per `deliveryId`, weil das UI sie immer „pro Lieferung" +/// braucht. +class TourDetails { + TourDetails({ + required this.tour, + required this.deliveries, + required this.customers, + required this.contacts, + required this.articles, + required this.warehouses, + required this.notesByDeliveryId, + required this.creditsByDeliveryId, + required this.services, + required this.serviceValuesByDeliveryId, + required this.contactSourcesByDeliveryId, + required this.contactChannelsBySourceId, + }); + + final Tour tour; + + /// Alle Lieferungen dieser Tour. Reihenfolge: unsortiert; UI ruft + /// `deliveriesSorted` auf, wenn Sortier-Reihenfolge benötigt wird. + final List deliveries; + + // ─── Stammdaten-Lookups (Id → Entity) ───────────────────────────────── + + final Map customers; + final Map contacts; + final Map articles; + final Map warehouses; + + /// Pro Lieferung: alle Notizen, aufsteigend nach `createdAt`. Wenn eine + /// Lieferung keine Notizen hat, liefert der Lookup `null` zurück — das + /// UI muss das berücksichtigen. + final Map> notesByDeliveryId; + + /// Pro Lieferung die aktuelle Betrags-Gutschrift (höchstens eine). Fehlt + /// der Eintrag, gibt es aktuell keine Gutschrift. + final Map creditsByDeliveryId; + + /// Aktive Service-Definitionen (Stammdaten), nach `sortOrder`. Daraus + /// rendert Phase 4 die Auswahl. + final List services; + + /// Pro Lieferung die gesetzten Service-Werte, indiziert per `serviceId`. + final Map> serviceValuesByDeliveryId; + + /// Pro Lieferung die Adress-Quellen aus dem ERP (Belegadresse / Liefer- + /// adresse / Rechnungsadresse / Ansprechpartner / Kundenstamm). Wird vom + /// Sync gefüllt; leere Quellen kommen nicht durch — wer hier 0 Einträge + /// sieht, hat im ERP keinen einzigen Kontakt am Beleg hängen. + final Map> contactSourcesByDeliveryId; + + /// Pro Quelle alle ihre Kommunikationskanäle. Reihenfolge folgt der + /// ERP-Position (Telefon 1 → Position 1, Telefon 2 → Position 2, …), + /// das UI kann die Liste direkt rendern. + final Map> contactChannelsBySourceId; + + // ─── Convenience für UI ─────────────────────────────────────────────── + + /// Lieferungen sortiert nach `sortOrder` aufsteigend. Falls zwei + /// Lieferungen identische Werte tragen (sollte nicht vorkommen, dient + /// nur als Defensive), fällt der Vergleich auf die Belegnummer zurück. + List get deliveriesSorted { + final copy = List.of(deliveries); + copy.sort((a, b) { + final byOrder = a.sortOrder.compareTo(b.sortOrder); + if (byOrder != 0) return byOrder; + return a.erpBelegnummer.compareTo(b.erpBelegnummer); + }); + return copy; + } + + Customer? customerOf(Delivery delivery) => customers[delivery.customerId]; + + Iterable contactsOf(Delivery delivery) sync* { + for (final id in delivery.contactPersonIds) { + final c = contacts[id]; + if (c != null) yield c; + } + } + + /// Alle Adress-Quellen einer Lieferung — in der vom Backend gelieferten + /// Reihenfolge (nach [ContactRole], anschließend nach Quell-Id für + /// stabile UI). Leere Liste, wenn diese Lieferung im ERP keinen Kontakt + /// hängen hat. + List contactSourcesOf(Delivery delivery) => + contactSourcesByDeliveryId[delivery.id] ?? const []; + + /// Alle Kanäle einer einzelnen Quelle. Leere Liste, wenn die Quelle nur + /// einen Namensblock trägt (z. B. ein Ansprechpartner ohne Telefonnummer). + List channelsOf(ContactSource source) => + contactChannelsBySourceId[source.id] ?? const []; + + /// Wie [contactSourcesOf], aber Quellen mit identischem Namensblock UND + /// identischer Channel-Liste sind zu einem [MergedContactSource] mit + /// Multi-Rollen-Header zusammengeführt. Das eliminiert die typische + /// Doppelung „Belegadresse + Kundenstamm" bei Belegen, deren + /// `Belegkopf.AdressId` ohnehin auf die Kunden-Stammadresse zeigt. + /// + /// Identity-Fingerprint: alle Namensfelder (Anrede / Titel / Name1..3 / + /// Abteilung / Funktion) plus die nach (kind, position) sortierten + /// (kind, value)-Paare. Zwei Quellen mit identischem Namen, aber + /// abweichenden Channels werden NICHT gemerged — das wäre fachlich + /// falsch (zwei verschiedene Kontaktdatensätze derselben Person). + List mergedContactSourcesOf(Delivery delivery) { + final sources = contactSourcesOf(delivery); + if (sources.isEmpty) return const []; + + // Reihenfolge der Erstauftritte merken — die Backend-Sortierung + // (Quellen nach Rolle aufsteigend) bestimmt damit auch die Reihenfolge + // der Merge-Gruppen in der UI. + final order = []; + final byKey = >{}; + for (final s in sources) { + final key = _identityKey(s, channelsOf(s)); + if (!byKey.containsKey(key)) { + order.add(key); + byKey[key] = []; + } + byKey[key]!.add(s); + } + + return [ + for (final key in order) _buildMerged(byKey[key]!), + ]; + } + + /// Fingerprint einer Quelle: Namensblock + alle (kind, position, value)- + /// Tripel. Vorab nach (kind-Index, position) sortiert, damit semantisch + /// gleiche Quellen unabhängig von der Speicher-Reihenfolge denselben + /// Schlüssel bekommen. + String _identityKey(ContactSource s, List channels) { + final namePart = [ + s.anrede ?? '', + s.titel ?? '', + s.name1 ?? '', + s.name2 ?? '', + s.name3 ?? '', + s.abteilung ?? '', + s.funktion ?? '', + ].join('|'); + final sortedChannels = List.of(channels) + ..sort((a, b) { + final byKind = a.kind.index.compareTo(b.kind.index); + if (byKind != 0) return byKind; + return a.position.compareTo(b.position); + }); + final channelPart = sortedChannels + .map((c) => '${c.kind.name}:${c.position}:${c.value}') + .join('|'); + return '$namePart||$channelPart'; + } + + MergedContactSource _buildMerged(List group) { + // Namensblock + Channels von der ersten Quelle übernehmen — alle Quellen + // in der Gruppe sind per Identity-Key garantiert deckungsgleich. + final first = group.first; + final roles = group.map((s) => s.role).toList() + ..sort((a, b) => a.index.compareTo(b.index)); + return MergedContactSource( + roles: roles, + anrede: first.anrede, + titel: first.titel, + name1: first.name1, + name2: first.name2, + name3: first.name3, + abteilung: first.abteilung, + funktion: first.funktion, + channels: channelsOf(first), + ); + } + + Article? articleOf(String articleId) => articles[articleId]; + + Warehouse? warehouseOf(String warehouseId) => warehouses[warehouseId]; + + List notesOf(String deliveryId) => + notesByDeliveryId[deliveryId] ?? const []; + + /// Aktuelle Betrags-Gutschrift dieser Lieferung, oder `null`. + DeliveryCredit? creditOf(String deliveryId) => + creditsByDeliveryId[deliveryId]; + + /// Gesetzter Service-Wert dieser Lieferung für einen Service, oder `null`. + DeliveryServiceValue? serviceValueOf(String deliveryId, String serviceId) => + serviceValuesByDeliveryId[deliveryId]?[serviceId]; + + /// Alle Attachment-IDs, die von Foto-Notizen dieser Tour referenziert + /// werden — die Menge der „noch gültigen" Bilder. Dient dem Cache-Pruning + /// (`AttachmentCache.retainOnly`): gecachte Vorschauen zu IDs, die hier + /// nicht (mehr) vorkommen, gehören zu gelöschten Notizen und dürfen weg. + Set get referencedAttachmentIds { + final ids = {}; + for (final notes in notesByDeliveryId.values) { + for (final n in notes) { + final attachment = n.imageAttachment; + if (attachment != null) ids.add(attachment); + } + } + return ids; + } + + bool isArticleScannable(String articleId) => + articles[articleId]?.scannable ?? false; + + /// Nicht-scanbare Positionen einer Lieferung (Dienstleistung / Pauschale / + /// Fracht — `article.scannable == false`). Entfernte Zeilen sind hier + /// ausgefiltert, weil eine entfernte Dienstleistung den Belade-/Anfahrt- + /// Hinweis nicht mehr rechtfertigt. + /// + /// Diese Positionen werden in der Beladen-Phase **nicht gescannt**, sind + /// aber fachlich der Grund, warum eine Lieferung ohne scanbare Ware (reine + /// Dienstleistung) trotzdem angefahren werden muss. + Iterable nonScannableItems(Delivery delivery) sync* { + for (final it in delivery.items) { + if (it.isRemoved) continue; + if (isArticleScannable(it.articleId)) continue; + yield it; + } + } + + /// `true`, wenn die Lieferung mindestens eine nicht-scanbare Position + /// (Dienstleistung / Pauschale) trägt — Basis für den Dienstleistungs- + /// Hinweis in der Beladen-Ansicht. + bool hasServiceItems(Delivery delivery) => + nonScannableItems(delivery).isNotEmpty; + + // ─── Lager-Aufteilung in der Beladen-Phase ─────────────────────────── + // + // Der Fahrer startet standardmäßig im Standardlager (`Warehouse.isStandard`). + // Filialen werden separat angefahren — sie blockieren NICHT den Übergang + // in die Auslieferungs-Phase. Eine Lieferung gilt deshalb als „fertig + // beladen", sobald **alle scanbaren Standardlager-Items** durch sind; + // Filial-Items werden in der UI sichtbar gekennzeichnet, damit der + // Fahrer weiß, dass er noch eine zweite Station ansteuern muss. + + bool _isStandard(String warehouseId) => + warehouseOf(warehouseId)?.isStandard ?? false; + + bool _isExternal(String warehouseId) { + final w = warehouseOf(warehouseId); + return w != null && !w.isStandard; + } + + /// Iterator über die scanbaren Items einer Lieferung. `includeRemoved` + /// kontrolliert, ob entfernte Positionen Teil der Iteration sind: + /// + /// * `false` (default) — für Status-Berechnungen (`standardWarehouseLoadingDone`, + /// `hasExternalWarehouseItems`, …). Entfernte Positionen blockieren + /// sonst „Fertig"-Marker oder triggern fälschlich Filial-Hinweise. + /// * `true` — für die UI-Anzeige (`itemsGroupedByWarehouse`), damit der + /// Fahrer entfernte Items als durchgestrichene Zeilen weiterhin sieht + /// und sie ggf. wiederherstellen kann. + Iterable _activeScannableItems( + Delivery delivery, { + bool includeRemoved = false, + }) sync* { + for (final it in delivery.items) { + if (!includeRemoved && it.isRemoved) continue; + if (!isArticleScannable(it.articleId)) continue; + yield it; + } + } + + /// Standardlager-Beladung dieser Lieferung ist erledigt: jedes scanbare, + /// nicht-entfernte Item aus dem Standardlager ist `done`. Lieferungen + /// ohne Standardlager-Items (= alles Filiale) sind trivial fertig — + /// im Standardlager ist dann nichts zu tun. + bool standardWarehouseLoadingDone(Delivery delivery) { + return _activeScannableItems(delivery) + .where((it) => _isStandard(it.warehouseId)) + .every((it) => it.isDone); + } + + /// Lieferung enthält mindestens ein noch relevantes Filial-Item. + /// „Relevant" = scanbar + nicht entfernt; ob das Item schon gescannt ist + /// oder nicht spielt für diese Markierung keine Rolle (entscheidend ist + /// nur, dass der Fahrer ein zusätzliches Lager anfahren muss). + bool hasExternalWarehouseItems(Delivery delivery) { + return _activeScannableItems(delivery).any( + (it) => _isExternal(it.warehouseId), + ); + } + + /// Filial-Items, die noch nicht beladen wurden — gedacht für die + /// Auslieferungs-Übersicht: dort soll der Fahrer auf einen Blick sehen, + /// dass er *vor* der Anfahrt zum Kunden noch ein zweites Lager ansteuern + /// muss, und welche Artikel ihn dort erwarten. + /// + /// Item-Filter: scanbar + nicht entfernt + Filiale + `!isDone`. Items + /// mit Status `held` zählen ebenfalls als „nicht geholt", weil das + /// Warenholen noch aussteht. + /// + /// Sortierung: Lager alphabetisch, innerhalb des Lagers nach + /// `belegzeilenNr` aufsteigend — stabile Reihenfolge zwischen Builds. + List<({Warehouse warehouse, List items})> + pendingExternalWarehouseGroups(Delivery delivery) { + final byWarehouseId = >{}; + for (final it in _activeScannableItems(delivery)) { + if (!_isExternal(it.warehouseId)) continue; + if (it.isDone) continue; + byWarehouseId.putIfAbsent(it.warehouseId, () => []).add(it); + } + final groups = <({Warehouse warehouse, List items})>[]; + byWarehouseId.forEach((warehouseId, items) { + final w = warehouseOf(warehouseId); + if (w == null) return; + items.sort((a, b) => a.belegzeilenNr.compareTo(b.belegzeilenNr)); + groups.add((warehouse: w, items: items)); + }); + groups.sort((a, b) => a.warehouse.name.compareTo(b.warehouse.name)); + return groups; + } + + /// `true`, wenn die Lieferung noch mindestens einen offenen + /// Filial-Artikel hat (= Fahrer muss zuerst in die Filiale). + bool hasPendingExternalWarehouseItems(Delivery delivery) { + for (final it in _activeScannableItems(delivery)) { + if (!_isExternal(it.warehouseId)) continue; + if (!it.isDone) return true; + } + return false; + } + + /// Eindeutige Filial-Namen dieser Lieferung — für Badges / + /// Sektions-Header in der UI. Sortiert nach Lager-Name, damit die + /// Reihenfolge stabil bleibt zwischen Builds. + List externalWarehouseLabels(Delivery delivery) { + final names = {}; + for (final it in _activeScannableItems(delivery)) { + if (!_isExternal(it.warehouseId)) continue; + final w = warehouseOf(it.warehouseId); + if (w != null) names.add(w.name); + } + final list = names.toList()..sort(); + return list; + } + + /// Gruppiert die scanbaren Items einer Lieferung nach Warehouse-Id — + /// Standardlager-Eintrag (sofern vorhanden) immer zuerst, danach + /// Filiale alphabetisch nach Lager-Name. Items innerhalb einer + /// Gruppe sind nach `belegzeilenNr` aufsteigend sortiert. + List<({Warehouse warehouse, List items})> + itemsGroupedByWarehouse(Delivery delivery) { + final byWarehouseId = >{}; + // Entfernte Items bleiben in der UI sichtbar (durchgestrichen) und + // können dort über das Aktions-Menü wiederhergestellt werden — der + // Status-Pfad (`standardWarehouseLoadingDone` etc.) ignoriert sie + // trotzdem, weil die jeweiligen Helper ohne `includeRemoved` laufen. + for (final it in _activeScannableItems(delivery, includeRemoved: true)) { + byWarehouseId.putIfAbsent(it.warehouseId, () => []).add(it); + } + for (final list in byWarehouseId.values) { + list.sort((a, b) => a.belegzeilenNr.compareTo(b.belegzeilenNr)); + } + + // In zwei Buckets aufteilen, damit der Aufrufer Standard zuerst sieht. + final standard = <({Warehouse warehouse, List items})>[]; + final external = <({Warehouse warehouse, List items})>[]; + byWarehouseId.forEach((warehouseId, items) { + final w = warehouseOf(warehouseId); + if (w == null) return; // Defensive: defekte Stammdaten ignorieren + final group = (warehouse: w, items: items); + if (w.isStandard) { + standard.add(group); + } else { + external.add(group); + } + }); + external.sort((a, b) => a.warehouse.name.compareTo(b.warehouse.name)); + return [...standard, ...external]; + } + + /// Neues Aggregat mit ausgetauschten/erweiterten Listen — gedacht für + /// Bloc-Reducer (Reorder, Assign-Car etc.), die das ganze Aggregat + /// behalten und nur ein paar Lieferungen austauschen wollen. + TourDetails copyWith({ + Tour? tour, + List? deliveries, + Map>? notesByDeliveryId, + Map? creditsByDeliveryId, + Map>? serviceValuesByDeliveryId, + }) { + return TourDetails( + tour: tour ?? this.tour, + deliveries: deliveries ?? this.deliveries, + customers: customers, + contacts: contacts, + articles: articles, + warehouses: warehouses, + notesByDeliveryId: notesByDeliveryId ?? this.notesByDeliveryId, + creditsByDeliveryId: creditsByDeliveryId ?? this.creditsByDeliveryId, + services: services, + serviceValuesByDeliveryId: + serviceValuesByDeliveryId ?? this.serviceValuesByDeliveryId, + contactSourcesByDeliveryId: contactSourcesByDeliveryId, + contactChannelsBySourceId: contactChannelsBySourceId, + ); + } + + /// Ersetzt eine einzelne Lieferung im Aggregat. Reihenfolge bleibt erhalten. + TourDetails replaceDelivery(Delivery updated) { + final next = List.of(deliveries); + final idx = next.indexWhere((d) => d.id == updated.id); + if (idx == -1) return this; + next[idx] = updated; + return copyWith(deliveries: next); + } +} diff --git a/lib/domain/entity/warehouse.dart b/lib/domain/entity/warehouse.dart new file mode 100644 index 0000000..b998a7f --- /dev/null +++ b/lib/domain/entity/warehouse.dart @@ -0,0 +1,32 @@ +/// Lager-Standort, von dem ein `DeliveryItem` geladen wird. +/// +/// `isStandard` markiert das Hauptlager — die App nutzt das, um in der +/// Loading-Übersicht ein „Sonderlager"-Banner zu zeigen, sobald Items aus +/// einem nicht-Standard-Lager kommen. +class Warehouse { + const Warehouse({ + required this.id, + required this.name, + required this.code, + required this.isStandard, + }); + + final String id; + final String name; + final String code; + final bool isStandard; + + Warehouse copyWith({ + String? id, + String? name, + String? code, + bool? isStandard, + }) { + return Warehouse( + id: id ?? this.id, + name: name ?? this.name, + code: code ?? this.code, + isStandard: isStandard ?? this.isStandard, + ); + } +} diff --git a/lib/domain/repository/payment_methods_repository.dart b/lib/domain/repository/payment_methods_repository.dart new file mode 100644 index 0000000..2034737 --- /dev/null +++ b/lib/domain/repository/payment_methods_repository.dart @@ -0,0 +1,36 @@ +import 'package:hl_lieferservice/domain/entity/payment_method.dart'; + +/// Port für Zahlungsmethoden — globale Stammdaten. +/// +/// Im Gegensatz zu `CarsRepository` keine Account-Filter: die Methoden +/// sind firmenweit, alle Fahrer sehen dieselbe Liste. +/// +/// Lösch-Verhalten: `delete` wirft eine `PaymentMethodsRepositoryException` +/// mit konkretem `409`-Fall, wenn die Methode noch von Lieferungen +/// referenziert wird (Backend hat dafür den FK-RESTRICT). Für „weiches +/// Entfernen" gibt es `update(active: false)`. +abstract interface class PaymentMethodsRepository { + Future> list({bool includeInactive = false}); + + Future create({ + required String code, + required String name, + }); + + Future update({ + required String id, + String? name, + bool? active, + }); + + Future delete(String id); +} + +class PaymentMethodsRepositoryException implements Exception { + const PaymentMethodsRepositoryException(this.message, [this.cause]); + final String message; + final Object? cause; + + @override + String toString() => 'PaymentMethodsRepositoryException: $message'; +} diff --git a/lib/domain/repository/tour_repository.dart b/lib/domain/repository/tour_repository.dart new file mode 100644 index 0000000..991c3cf --- /dev/null +++ b/lib/domain/repository/tour_repository.dart @@ -0,0 +1,210 @@ +import 'package:hl_lieferservice/domain/entity/delivery.dart'; +import 'package:hl_lieferservice/domain/entity/delivery_credit.dart'; +import 'package:hl_lieferservice/domain/entity/delivery_note.dart'; +import 'package:hl_lieferservice/domain/entity/delivery_service_value.dart'; +import 'package:hl_lieferservice/domain/entity/scan_intent.dart'; +import 'package:hl_lieferservice/domain/entity/tour.dart'; +import 'package:hl_lieferservice/domain/entity/tour_details.dart'; + +/// Port für das Tour-Aggregat. +/// +/// Der Port deckt in dieser Migrations-Phase nur die Read-Seite + die +/// beiden Operationen, die zum Loading-Flow zwingend gebraucht werden: +/// Sortierung und Fahrzeug-Zuweisung. Hold/Resume/Cancel/Complete und +/// Notizen werden in C+D-4 nachgezogen, damit das hier nicht überladen +/// wird und der Bloc fokussiert bleibt. +/// +/// Account-Filter sitzt serverseitig im JWT — der Client schickt nie eine +/// `personalnummer`/`accountId` mit. +abstract interface class TourRepository { + /// Die heutige Tour-Übersicht des angemeldeten Fahrers oder `null`, + /// wenn keine Tour für heute angelegt ist (ERP-Sync noch nicht + /// gelaufen, Treiber-Urlaub etc.). + /// + /// Liefert nur die schlanke `TourSummary`-Repräsentation; + /// [getTourDetails] zieht dann den vollen Aggregat-Snapshot. + Future getMyTourSummaryOfToday(); + + /// Lädt das volle Tour-Aggregat (Tour + Lieferungen + Items + + /// Stammdaten + Notizen) für die gegebene Tour-Id. + Future getTourDetails(String tourId); + + /// Convenience: kombiniert [getMyTourSummaryOfToday] + [getTourDetails] + /// und gibt `null` zurück, wenn keine Tour existiert. Verwendet die App + /// beim Initial-Load. + Future getMyTourDetailsOfToday(); + + /// Schreibt die Sortier-Reihenfolge der Lieferungen einer Tour neu. + /// + /// `orderedDeliveryIds` muss **alle** Lieferungen der Tour enthalten, + /// in der gewünschten Reihenfolge — das Backend lehnt unvollständige + /// Listen mit `400 validation` ab. + /// + /// Rückgabe: deliveryId → neuer sortOrder (für den Bloc-Reducer). + Future> setDeliveryOrder({ + required String tourId, + required List orderedDeliveryIds, + }); + + /// Weist einer Lieferung ein Fahrzeug zu. `carId == null` löst die + /// bestehende Zuweisung. Der Server gibt die aktualisierte Delivery + /// zurück; weil dieser Endpoint nur Stamm-Felder mutiert, ist es Aufgabe + /// des Aufrufers, die `items` aus dem lokalen Aggregat zu erhalten. + /// + /// Rückgabe: die Stamm-Delivery **ohne** Items — Aufrufer nutzt + /// `copyWith(items: ...)` zum Mergen mit dem lokalen State. + Future assignCarToDelivery({ + required String deliveryId, + required String? carId, + }); + + /// Bricht eine Lieferung ab — endgültig (`canceled`). `reason` ist + /// vom Backend Pflicht; leere Begründungen werden mit 400 abgelehnt. + /// Rückgabe: Server-Snapshot der Delivery **ohne** Items. + Future cancelDelivery({ + required String deliveryId, + required String reason, + }); + + /// Pausiert eine Lieferung (`held`). Reversibel über [resumeDelivery]. + /// `reason` ist Pflicht. + Future holdDelivery({ + required String deliveryId, + required String reason, + }); + + /// Setzt eine pausierte Lieferung auf `active` zurück. Kein Reason + /// erforderlich. + Future resumeDelivery({required String deliveryId}); + + /// Schließt eine Lieferung ab (`completed`). Lädt beide Unterschriften + /// (Kunde + Fahrer, PNG) per multipart hoch und dokumentiert die + /// Bestätigungen des Kunden. Atomar serverseitig — das Backend prüft + /// vorher: Lieferung aktiv, alle scanbaren Positionen fertig, Notizen + /// bestätigt (falls vorhanden). [paymentMethodId] persistiert die ggf. im + /// Summary geänderte Zahlungsmethode (muss existieren + aktiv sein); `null` + /// lässt die am Beleg hinterlegte Methode unangetastet. Rückgabe: + /// Server-Snapshot der Delivery **ohne** Items (Aufrufer merged Items aus + /// dem lokalen Aggregat). + Future completeDelivery({ + required String deliveryId, + required List customerSignaturePng, + required List driverSignaturePng, + required bool receiptConfirmed, + required bool notesAcknowledged, + required List acknowledgedNoteIds, + String? paymentMethodId, + String? actorCarId, + bool paymentCollected = false, + }); + + /// Legt eine neue Notiz an einer Lieferung an. + /// + /// Mindestens eines von [text] und [imageAttachment] muss inhaltlich + /// gefüllt sein — das Backend erzwingt das. Aktuell unterstützt die App + /// nur den Text-Pfad; das `imageAttachment`-Feld bleibt der zukünftigen + /// Foto-Upload-Phase vorbehalten. + /// + /// Rückgabe: die neu angelegte Notiz (mit Server-gesetzter `id` und + /// `createdAt`) — der Aufrufer hängt sie an das lokale Tour-Aggregat. + Future addDeliveryNote({ + required String deliveryId, + String? text, + String? imageAttachment, + String? creditDeliveryItemId, + bool isAmountCreditNote, + }); + + /// Ändert Text/Bild einer bestehenden Notiz. Mindestens eines von [text] + /// und [imageAttachment] muss inhaltlich gefüllt sein. Rückgabe: die + /// aktualisierte Notiz (Autor/`createdAt` bleiben). + Future updateDeliveryNote({ + required String deliveryId, + required String noteId, + String? text, + String? imageAttachment, + }); + + /// Löscht eine Notiz. Innerhalb des (geteilten) Accounts darf jeder Fahrer + /// löschen — keine Autor-Prüfung serverseitig. + Future deleteDeliveryNote({ + required String deliveryId, + required String noteId, + }); + + /// Lädt ein Bild zu einer Lieferung hoch (multipart, Feld `file`). Das + /// Backend reicht es an DOCUframe weiter und legt eine Bild-Notiz mit der + /// zurückgelieferten Referenz (`~ObjectID`) als `imageAttachment` an. + /// Rückgabe: die neue Notiz. + Future uploadDeliveryNoteImage({ + required String deliveryId, + required String filename, + required String mime, + required List bytes, + }); + + /// Setzt/ändert die Betrags-Gutschrift einer Lieferung. Append-only + + /// idempotent über [clientEventId]. Rückgabe: aktueller Stand (`null`, wenn + /// — theoretisch — nichts gesetzt ist). + Future setDeliveryCredit({ + required String deliveryId, + required String clientEventId, + required int amountCents, + required String reason, + String? actorCarId, + }); + + /// Entfernt die Betrags-Gutschrift einer Lieferung (append-only `remove`). + /// Rückgabe: aktueller Stand danach (`null`). + Future removeDeliveryCredit({ + required String deliveryId, + required String clientEventId, + String? actorCarId, + }); + + /// Setzt (Upsert) den Wert eines Service für eine Lieferung. Genau das zum + /// Service-Typ passende Feld angeben. Rückgabe: der gespeicherte Wert. + Future setDeliveryService({ + required String deliveryId, + required String serviceId, + bool? boolValue, + int? numericValue, + String? actorCarId, + }); + + /// Entfernt den Service-Wert einer Lieferung (Service „nicht gesetzt"). + Future removeDeliveryService({ + required String deliveryId, + required String serviceId, + }); + + /// Wendet eine Liste Scan-Ereignisse als Batch am Server an. + /// + /// Der Endpoint ist bewusst Bulk: damit kann der Client einen + /// Scanner-Burst (z. B. 5 Barcodes in 2 Sekunden) in einem HTTP-Call + /// abschicken, **muss** aber nicht — auch ein Aufruf mit nur einem + /// `ScanIntent` ist erlaubt. + /// + /// Idempotenz: das Backend speichert pro `clientScanId` einmal. Wer + /// retried, bekommt `duplicate` zurück; doppelte Anwendung kann es + /// nicht geben. + /// + /// Rückgabe: pro Eingabe-Intent ein [ScanOutcome] (Key = + /// `clientScanId`). Die Map enthält **jeden** Intent, auch + /// `rejected`-Fälle; bei Netzwerk-/Server-Fehlern wirft das Repository + /// stattdessen [TourRepositoryException], die Map ist dann nicht + /// teilweise gefüllt. + Future> applyScans(List intents); +} + +/// Allgemeine Repository-Exception für Tour-Operationen. Konkrete Impls +/// dürfen spezifischere Subtypen werfen. +class TourRepositoryException implements Exception { + const TourRepositoryException(this.message, [this.cause]); + + final String message; + final Object? cause; + + @override + String toString() => 'TourRepositoryException: $message'; +} diff --git a/lib/dto/address.dart b/lib/dto/address.dart deleted file mode 100644 index 53c76ea..0000000 --- a/lib/dto/address.dart +++ /dev/null @@ -1,20 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; - -part 'address.g.dart'; - -@JsonSerializable(fieldRename: FieldRename.snake) -class AddressDTO { - AddressDTO( - {required this.streetName, - required this.postalCode, - required this.city}); - - String streetName; - String postalCode; - String city; - - factory AddressDTO.fromJson(Map json) => - _$AddressDTOFromJson(json); - - Map toJson() => _$AddressDTOToJson(this); -} \ No newline at end of file diff --git a/lib/dto/address.g.dart b/lib/dto/address.g.dart deleted file mode 100644 index 2c7df80..0000000 --- a/lib/dto/address.g.dart +++ /dev/null @@ -1,20 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'address.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -AddressDTO _$AddressDTOFromJson(Map json) => AddressDTO( - streetName: json['street_name'] as String, - postalCode: json['postal_code'] as String, - city: json['city'] as String, -); - -Map _$AddressDTOToJson(AddressDTO instance) => - { - 'street_name': instance.streetName, - 'postal_code': instance.postalCode, - 'city': instance.city, - }; diff --git a/lib/dto/article.dart b/lib/dto/article.dart deleted file mode 100644 index af233c4..0000000 --- a/lib/dto/article.dart +++ /dev/null @@ -1,45 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; - -import 'component.dart'; - -part 'article.g.dart'; - -@JsonSerializable(fieldRename: FieldRename.snake) -class ArticleDTO { - ArticleDTO({ - required this.name, - required this.articleNr, - required this.quantity, - required this.price, - required this.scannable, - required this.internalId, - required this.scannedRemovedAmount, - required this.scannedAmount, - required this.removeNoteId, - required this.taxRate, - required this.isParent, - this.components, - this.warehouseNr, - this.warehouseName, - }); - - String name; - String articleNr; - String quantity; - String price; - String taxRate; - String internalId; - String scannedAmount; - String scannedRemovedAmount; - String? removeNoteId; - bool scannable; - bool isParent; - List? components; - String? warehouseNr; - String? warehouseName; - - factory ArticleDTO.fromJson(Map json) => - _$ArticleDTOFromJson(json); - - Map toJson() => _$ArticleDTOToJson(this); -} diff --git a/lib/dto/article.g.dart b/lib/dto/article.g.dart deleted file mode 100644 index ca1fbc3..0000000 --- a/lib/dto/article.g.dart +++ /dev/null @@ -1,45 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'article.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -ArticleDTO _$ArticleDTOFromJson(Map json) => ArticleDTO( - name: json['name'] as String, - articleNr: json['article_nr'] as String, - quantity: json['quantity'] as String, - price: json['price'] as String, - scannable: json['scannable'] as bool, - internalId: json['internal_id'] as String, - scannedRemovedAmount: json['scanned_removed_amount'] as String, - scannedAmount: json['scanned_amount'] as String, - removeNoteId: json['remove_note_id'] as String?, - taxRate: json['tax_rate'] as String, - isParent: json['is_parent'] as bool, - components: - (json['components'] as List?) - ?.map((e) => ComponentDTO.fromJson(e as Map)) - .toList(), - warehouseNr: json['warehouse_nr'] as String?, - warehouseName: json['warehouse_name'] as String?, -); - -Map _$ArticleDTOToJson(ArticleDTO instance) => - { - 'name': instance.name, - 'article_nr': instance.articleNr, - 'quantity': instance.quantity, - 'price': instance.price, - 'tax_rate': instance.taxRate, - 'internal_id': instance.internalId, - 'scanned_amount': instance.scannedAmount, - 'scanned_removed_amount': instance.scannedRemovedAmount, - 'remove_note_id': instance.removeNoteId, - 'scannable': instance.scannable, - 'is_parent': instance.isParent, - 'components': instance.components, - 'warehouse_nr': instance.warehouseNr, - 'warehouse_name': instance.warehouseName, - }; diff --git a/lib/dto/basic_response.dart b/lib/dto/basic_response.dart deleted file mode 100644 index 7def3b1..0000000 --- a/lib/dto/basic_response.dart +++ /dev/null @@ -1,16 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; - -part 'basic_response.g.dart'; - -@JsonSerializable(fieldRename: FieldRename.snake) -class BasicResponseDTO { - BasicResponseDTO( - {required this.succeeded, - required this.message}); - - final bool succeeded; - final String message; - - factory BasicResponseDTO.fromJson(Map json) => _$BasicResponseDTOFromJson(json); - Map toJson() => _$BasicResponseDTOToJson(this); -} diff --git a/lib/dto/basic_response.g.dart b/lib/dto/basic_response.g.dart deleted file mode 100644 index 405bf6d..0000000 --- a/lib/dto/basic_response.g.dart +++ /dev/null @@ -1,19 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'basic_response.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -BasicResponseDTO _$BasicResponseDTOFromJson(Map json) => - BasicResponseDTO( - succeeded: json['succeeded'] as bool, - message: json['message'] as String, - ); - -Map _$BasicResponseDTOToJson(BasicResponseDTO instance) => - { - 'succeeded': instance.succeeded, - 'message': instance.message, - }; diff --git a/lib/dto/car.dart b/lib/dto/car.dart deleted file mode 100644 index 70a4851..0000000 --- a/lib/dto/car.dart +++ /dev/null @@ -1,16 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; - -part 'car.g.dart'; - -@JsonSerializable(fieldRename: FieldRename.snake) -class CarDTO { - CarDTO( - {required this.id, - required this.plate}); - - final String id; - final String plate; - - factory CarDTO.fromJson(Map json) => _$CarDTOFromJson(json); - Map toJson() => _$CarDTOToJson(this); -} diff --git a/lib/dto/car.g.dart b/lib/dto/car.g.dart deleted file mode 100644 index 135e5a4..0000000 --- a/lib/dto/car.g.dart +++ /dev/null @@ -1,15 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'car.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -CarDTO _$CarDTOFromJson(Map json) => - CarDTO(id: json['id'] as String, plate: json['plate'] as String); - -Map _$CarDTOToJson(CarDTO instance) => { - 'id': instance.id, - 'plate': instance.plate, -}; diff --git a/lib/dto/car_add.dart b/lib/dto/car_add.dart deleted file mode 100644 index 64550be..0000000 --- a/lib/dto/car_add.dart +++ /dev/null @@ -1,20 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; - -part 'car_add.g.dart'; - -@JsonSerializable(fieldRename: FieldRename.snake) -class CarAddDTO { - CarAddDTO( - {required this.teamId, - required this.plate}); - - final int teamId; - final String plate; - - factory CarAddDTO.fromJson(Map json) => _$CarAddDTOFromJson(json); - factory CarAddDTO.make(int teamID, String plate) { - Map data = {"team_id": teamID, "plate": plate}; - return CarAddDTO.fromJson(data); - } - Map toJson() => _$CarAddDTOToJson(this); -} diff --git a/lib/dto/car_add.g.dart b/lib/dto/car_add.g.dart deleted file mode 100644 index f15f9af..0000000 --- a/lib/dto/car_add.g.dart +++ /dev/null @@ -1,17 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'car_add.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -CarAddDTO _$CarAddDTOFromJson(Map json) => CarAddDTO( - teamId: (json['team_id'] as num).toInt(), - plate: json['plate'] as String, -); - -Map _$CarAddDTOToJson(CarAddDTO instance) => { - 'team_id': instance.teamId, - 'plate': instance.plate, -}; diff --git a/lib/dto/car_add_response.dart b/lib/dto/car_add_response.dart deleted file mode 100644 index a63b3cf..0000000 --- a/lib/dto/car_add_response.dart +++ /dev/null @@ -1,19 +0,0 @@ -import 'car.dart'; -import 'package:json_annotation/json_annotation.dart'; - -part 'car_add_response.g.dart'; - -@JsonSerializable(fieldRename: FieldRename.snake) -class CarAddResponseDTO { - CarAddResponseDTO( - {required this.succeeded, - required this.message, - required this.car}); - - final bool succeeded; - final String message; - final CarDTO car; - - factory CarAddResponseDTO.fromJson(Map json) => _$CarAddResponseDTOFromJson(json); - Map toJson() => _$CarAddResponseDTOToJson(this); -} diff --git a/lib/dto/car_add_response.g.dart b/lib/dto/car_add_response.g.dart deleted file mode 100644 index 3344fe1..0000000 --- a/lib/dto/car_add_response.g.dart +++ /dev/null @@ -1,21 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'car_add_response.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -CarAddResponseDTO _$CarAddResponseDTOFromJson(Map json) => - CarAddResponseDTO( - succeeded: json['succeeded'] as bool, - message: json['message'] as String, - car: CarDTO.fromJson(json['car'] as Map), - ); - -Map _$CarAddResponseDTOToJson(CarAddResponseDTO instance) => - { - 'succeeded': instance.succeeded, - 'message': instance.message, - 'car': instance.car, - }; diff --git a/lib/dto/car_get_response.dart b/lib/dto/car_get_response.dart deleted file mode 100644 index 3074b94..0000000 --- a/lib/dto/car_get_response.dart +++ /dev/null @@ -1,19 +0,0 @@ -import 'car.dart'; -import 'package:json_annotation/json_annotation.dart'; - -part 'car_get_response.g.dart'; - -@JsonSerializable(fieldRename: FieldRename.snake) -class CarGetResponseDTO { - CarGetResponseDTO( - {required this.succeeded, - required this.message, - required this.cars}); - - final bool succeeded; - final String message; - final List? cars; - - factory CarGetResponseDTO.fromJson(Map json) => _$CarGetResponseDTOFromJson(json); - Map toJson() => _$CarGetResponseDTOToJson(this); -} diff --git a/lib/dto/car_get_response.g.dart b/lib/dto/car_get_response.g.dart deleted file mode 100644 index bb2ddb7..0000000 --- a/lib/dto/car_get_response.g.dart +++ /dev/null @@ -1,24 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'car_get_response.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -CarGetResponseDTO _$CarGetResponseDTOFromJson(Map json) => - CarGetResponseDTO( - succeeded: json['succeeded'] as bool, - message: json['message'] as String, - cars: - (json['cars'] as List?) - ?.map((e) => CarDTO.fromJson(e as Map)) - .toList(), - ); - -Map _$CarGetResponseDTOToJson(CarGetResponseDTO instance) => - { - 'succeeded': instance.succeeded, - 'message': instance.message, - 'cars': instance.cars, - }; diff --git a/lib/dto/component.dart b/lib/dto/component.dart deleted file mode 100644 index bb8d33c..0000000 --- a/lib/dto/component.dart +++ /dev/null @@ -1,23 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; - -part 'component.g.dart'; - -@JsonSerializable(fieldRename: FieldRename.snake) -class ComponentDTO { - ComponentDTO({ - required this.articleNr, - required this.name, - required this.quantity, - required this.pos, - }); - - String articleNr; - String name; - String quantity; - String pos; - - factory ComponentDTO.fromJson(Map json) => - _$ComponentDTOFromJson(json); - - Map toJson() => _$ComponentDTOToJson(this); -} diff --git a/lib/dto/component.g.dart b/lib/dto/component.g.dart deleted file mode 100644 index 8196616..0000000 --- a/lib/dto/component.g.dart +++ /dev/null @@ -1,22 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'component.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -ComponentDTO _$ComponentDTOFromJson(Map json) => ComponentDTO( - articleNr: json['article_nr'] as String, - name: json['name'] as String, - quantity: json['quantity'] as String, - pos: json['pos'] as String, -); - -Map _$ComponentDTOToJson(ComponentDTO instance) => - { - 'article_nr': instance.articleNr, - 'name': instance.name, - 'quantity': instance.quantity, - 'pos': instance.pos, - }; diff --git a/lib/dto/contact_person.dart b/lib/dto/contact_person.dart deleted file mode 100644 index c567916..0000000 --- a/lib/dto/contact_person.dart +++ /dev/null @@ -1,20 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; - -part 'contact_person.g.dart'; - -@JsonSerializable(fieldRename: FieldRename.snake) -class ContactPersonDTO { - ContactPersonDTO( - {required this.name, - required this.salutation, - required this.phoneNo, - required this.mobileNo}); - - String name; - String salutation; - String phoneNo; - String mobileNo; - - factory ContactPersonDTO.fromJson(Map json) => _$ContactPersonDTOFromJson(json); - Map toJson() => _$ContactPersonDTOToJson(this); -} diff --git a/lib/dto/contact_person.g.dart b/lib/dto/contact_person.g.dart deleted file mode 100644 index 2bc50ed..0000000 --- a/lib/dto/contact_person.g.dart +++ /dev/null @@ -1,23 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'contact_person.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -ContactPersonDTO _$ContactPersonDTOFromJson(Map json) => - ContactPersonDTO( - name: json['name'] as String, - salutation: json['salutation'] as String, - phoneNo: json['phone_no'] as String, - mobileNo: json['mobile_no'] as String, - ); - -Map _$ContactPersonDTOToJson(ContactPersonDTO instance) => - { - 'name': instance.name, - 'salutation': instance.salutation, - 'phone_no': instance.phoneNo, - 'mobile_no': instance.mobileNo, - }; diff --git a/lib/dto/customer.dart b/lib/dto/customer.dart deleted file mode 100644 index 3ebebf9..0000000 --- a/lib/dto/customer.dart +++ /dev/null @@ -1,16 +0,0 @@ -import 'address.dart'; -import 'package:json_annotation/json_annotation.dart'; - -part 'customer.g.dart'; - -@JsonSerializable(fieldRename: FieldRename.snake) -class CustomerDTO { - CustomerDTO({required this.name, required this.address, this.eMail}); - - String name; - AddressDTO address; - String? eMail; - - factory CustomerDTO.fromJson(Map json) => _$CustomerDTOFromJson(json); - Map toJson() => _$CustomerDTOToJson(this); -} \ No newline at end of file diff --git a/lib/dto/customer.g.dart b/lib/dto/customer.g.dart deleted file mode 100644 index bd772e1..0000000 --- a/lib/dto/customer.g.dart +++ /dev/null @@ -1,20 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'customer.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -CustomerDTO _$CustomerDTOFromJson(Map json) => CustomerDTO( - name: json['name'] as String, - address: AddressDTO.fromJson(json['address'] as Map), - eMail: json['e_mail'] as String?, -); - -Map _$CustomerDTOToJson(CustomerDTO instance) => - { - 'name': instance.name, - 'address': instance.address, - 'e_mail': instance.eMail, - }; diff --git a/lib/dto/delivery.dart b/lib/dto/delivery.dart deleted file mode 100644 index 258bea9..0000000 --- a/lib/dto/delivery.dart +++ /dev/null @@ -1,84 +0,0 @@ -import 'package:hl_lieferservice/dto/image_note_response.dart'; - -import 'article.dart'; -import 'contact_person.dart'; -import 'customer.dart'; -import 'discount.dart'; -import 'note.dart'; -import 'payment.dart'; -import 'package:json_annotation/json_annotation.dart'; - -part 'delivery.g.dart'; - -@JsonSerializable(fieldRename: FieldRename.snake) -class DeliveryOptionDTO { - DeliveryOptionDTO({ - required this.numerical, - required this.value, - required this.display, - required this.key, - }); - - bool numerical; - String value; - String display; - String key; - - factory DeliveryOptionDTO.fromJson(Map json) => - _$DeliveryOptionDTOFromJson(json); - - Map toJson() => _$DeliveryOptionDTOToJson(this); -} - -@JsonSerializable(fieldRename: FieldRename.snake) -class DeliveryDTO { - DeliveryDTO({ - required this.internalReceiptNo, - required this.specialAggreements, - required this.currency, - required this.notes, - required this.totalPrice, - required this.prepayment, - required this.paymentAtDelivery, - required this.desiredTime, - required this.contactPerson, - required this.articles, - required this.totalNetValue, - required this.totalGrossValue, - required this.images, - required this.customer, - required this.finishedTime, - required this.note, - required this.state, - required this.payment, - required this.carId, - required this.options, - }); - - String internalReceiptNo; - String? specialAggreements; - CustomerDTO customer; - String totalPrice; - String desiredTime; - String totalGrossValue; - String totalNetValue; - ContactPersonDTO contactPerson; - String? currency; - List articles; - String note; - String finishedTime; - String carId; - String state; - String prepayment; - String paymentAtDelivery; - DiscountDTO? discount; - PaymentMethodDTO payment; - List notes; - List images; - List options; - - factory DeliveryDTO.fromJson(Map json) => - _$DeliveryDTOFromJson(json); - - Map toJson() => _$DeliveryDTOToJson(this); -} diff --git a/lib/dto/delivery.g.dart b/lib/dto/delivery.g.dart deleted file mode 100644 index 03a4c29..0000000 --- a/lib/dto/delivery.g.dart +++ /dev/null @@ -1,89 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'delivery.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -DeliveryOptionDTO _$DeliveryOptionDTOFromJson(Map json) => - DeliveryOptionDTO( - numerical: json['numerical'] as bool, - value: json['value'] as String, - display: json['display'] as String, - key: json['key'] as String, - ); - -Map _$DeliveryOptionDTOToJson(DeliveryOptionDTO instance) => - { - 'numerical': instance.numerical, - 'value': instance.value, - 'display': instance.display, - 'key': instance.key, - }; - -DeliveryDTO _$DeliveryDTOFromJson(Map json) => DeliveryDTO( - internalReceiptNo: json['internal_receipt_no'] as String, - specialAggreements: json['special_aggreements'] as String?, - currency: json['currency'] as String?, - notes: - (json['notes'] as List) - .map((e) => NoteDTO.fromJson(e as Map)) - .toList(), - totalPrice: json['total_price'] as String, - prepayment: json['prepayment'] as String, - paymentAtDelivery: json['payment_at_delivery'] as String, - desiredTime: json['desired_time'] as String, - contactPerson: ContactPersonDTO.fromJson( - json['contact_person'] as Map, - ), - articles: - (json['articles'] as List) - .map((e) => ArticleDTO.fromJson(e as Map)) - .toList(), - totalNetValue: json['total_net_value'] as String, - totalGrossValue: json['total_gross_value'] as String, - images: - (json['images'] as List) - .map((e) => ImageNoteDTO.fromJson(e as Map)) - .toList(), - customer: CustomerDTO.fromJson(json['customer'] as Map), - finishedTime: json['finished_time'] as String, - note: json['note'] as String, - state: json['state'] as String, - payment: PaymentMethodDTO.fromJson(json['payment'] as Map), - carId: json['car_id'] as String, - options: - (json['options'] as List) - .map((e) => DeliveryOptionDTO.fromJson(e as Map)) - .toList(), - ) - ..discount = - json['discount'] == null - ? null - : DiscountDTO.fromJson(json['discount'] as Map); - -Map _$DeliveryDTOToJson(DeliveryDTO instance) => - { - 'internal_receipt_no': instance.internalReceiptNo, - 'special_aggreements': instance.specialAggreements, - 'customer': instance.customer, - 'total_price': instance.totalPrice, - 'desired_time': instance.desiredTime, - 'total_gross_value': instance.totalGrossValue, - 'total_net_value': instance.totalNetValue, - 'contact_person': instance.contactPerson, - 'currency': instance.currency, - 'articles': instance.articles, - 'note': instance.note, - 'finished_time': instance.finishedTime, - 'car_id': instance.carId, - 'state': instance.state, - 'prepayment': instance.prepayment, - 'payment_at_delivery': instance.paymentAtDelivery, - 'discount': instance.discount, - 'payment': instance.payment, - 'notes': instance.notes, - 'images': instance.images, - 'options': instance.options, - }; diff --git a/lib/dto/delivery_response.dart b/lib/dto/delivery_response.dart deleted file mode 100644 index eb31efe..0000000 --- a/lib/dto/delivery_response.dart +++ /dev/null @@ -1,22 +0,0 @@ -import 'delivery.dart'; -import 'driver.dart'; -import 'package:json_annotation/json_annotation.dart'; - -part 'delivery_response.g.dart'; - -@JsonSerializable(fieldRename: FieldRename.snake) -class DeliveryResponseDTO { - DeliveryResponseDTO( - {required this.deliveries, - required this.driver, - required this.discountArticleNumber}); - - List deliveries; - DriverDTO driver; - String discountArticleNumber; - - factory DeliveryResponseDTO.fromJson(Map json) => - _$DeliveryResponseDTOFromJson(json); - - Map toJson() => _$DeliveryResponseDTOToJson(this); -} diff --git a/lib/dto/delivery_response.g.dart b/lib/dto/delivery_response.g.dart deleted file mode 100644 index b4f71d4..0000000 --- a/lib/dto/delivery_response.g.dart +++ /dev/null @@ -1,25 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'delivery_response.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -DeliveryResponseDTO _$DeliveryResponseDTOFromJson(Map json) => - DeliveryResponseDTO( - deliveries: - (json['deliveries'] as List) - .map((e) => DeliveryDTO.fromJson(e as Map)) - .toList(), - driver: DriverDTO.fromJson(json['driver'] as Map), - discountArticleNumber: json['discount_article_number'] as String, - ); - -Map _$DeliveryResponseDTOToJson( - DeliveryResponseDTO instance, -) => { - 'deliveries': instance.deliveries, - 'driver': instance.driver, - 'discount_article_number': instance.discountArticleNumber, -}; diff --git a/lib/dto/delivery_update.dart b/lib/dto/delivery_update.dart deleted file mode 100644 index 7aa86c3..0000000 --- a/lib/dto/delivery_update.dart +++ /dev/null @@ -1,85 +0,0 @@ -import 'package:hl_lieferservice/model/delivery.dart'; -import 'package:intl/intl.dart'; -import 'package:json_annotation/json_annotation.dart'; - -part 'delivery_update.g.dart'; - -@JsonSerializable(fieldRename: FieldRename.snake) -class DeliveryOptionUpdateDTO { - DeliveryOptionUpdateDTO({ - required this.numerical, - required this.value, - required this.key, - }); - - bool numerical; - String value; - String key; - - factory DeliveryOptionUpdateDTO.fromJson(Map json) => - _$DeliveryOptionUpdateDTOFromJson(json); - - Map toJson() => _$DeliveryOptionUpdateDTOToJson(this); - - factory DeliveryOptionUpdateDTO.fromEntity(DeliveryOption option) { - return DeliveryOptionUpdateDTO( - numerical: option.numerical, - value: option.value, - key: option.key, - ); - } -} - -@JsonSerializable(fieldRename: FieldRename.snake) -class DeliveryUpdateDTO { - DeliveryUpdateDTO({ - required this.deliveryId, - this.finishedDate, - this.selectedPaymentMethodId, - this.options, - this.state, - this.carId, - }); - - String deliveryId; - String? finishedDate; - String? state; - String? carId; - String? selectedPaymentMethodId; - List? options; - - factory DeliveryUpdateDTO.fromJson(Map json) => - _$DeliveryUpdateDTOFromJson(json); - - factory DeliveryUpdateDTO.fromEntity(Delivery delivery) { - String state = ""; - - switch (delivery.state) { - case DeliveryState.finished: - state = "geliefert"; - break; - case DeliveryState.ongoing: - state = "laufend"; - break; - case DeliveryState.onhold: - state = "vertagt"; - break; - case DeliveryState.canceled: - state = "abgebrochen"; - break; - } - - return DeliveryUpdateDTO( - deliveryId: delivery.id, - state: state, - carId: delivery.carId?.toString() , - selectedPaymentMethodId: delivery.payment.id, - options: delivery.options.map(DeliveryOptionUpdateDTO.fromEntity).toList(), - finishedDate: delivery.state == DeliveryState.finished - ? DateFormat('yyyy-MM-dd HH:mm').format(DateTime.now()) - : null, - ); - } - - Map toJson() => _$DeliveryUpdateDTOToJson(this); -} diff --git a/lib/dto/delivery_update.g.dart b/lib/dto/delivery_update.g.dart deleted file mode 100644 index aa2e18a..0000000 --- a/lib/dto/delivery_update.g.dart +++ /dev/null @@ -1,49 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'delivery_update.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -DeliveryOptionUpdateDTO _$DeliveryOptionUpdateDTOFromJson( - Map json, -) => DeliveryOptionUpdateDTO( - numerical: json['numerical'] as bool, - value: json['value'] as String, - key: json['key'] as String, -); - -Map _$DeliveryOptionUpdateDTOToJson( - DeliveryOptionUpdateDTO instance, -) => { - 'numerical': instance.numerical, - 'value': instance.value, - 'key': instance.key, -}; - -DeliveryUpdateDTO _$DeliveryUpdateDTOFromJson(Map json) => - DeliveryUpdateDTO( - deliveryId: json['delivery_id'] as String, - finishedDate: json['finished_date'] as String?, - selectedPaymentMethodId: json['selected_payment_method_id'] as String?, - options: - (json['options'] as List?) - ?.map( - (e) => - DeliveryOptionUpdateDTO.fromJson(e as Map), - ) - .toList(), - state: json['state'] as String?, - carId: json['car_id'] as String?, - ); - -Map _$DeliveryUpdateDTOToJson(DeliveryUpdateDTO instance) => - { - 'delivery_id': instance.deliveryId, - 'finished_date': instance.finishedDate, - 'state': instance.state, - 'car_id': instance.carId, - 'selected_payment_method_id': instance.selectedPaymentMethodId, - 'options': instance.options, - }; diff --git a/lib/dto/delivery_update_response.dart b/lib/dto/delivery_update_response.dart deleted file mode 100644 index 737201e..0000000 --- a/lib/dto/delivery_update_response.dart +++ /dev/null @@ -1,17 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; - -part 'delivery_update_response.g.dart'; - -@JsonSerializable(fieldRename: FieldRename.snake) -class DeliveryUpdateResponseDTO { - DeliveryUpdateResponseDTO( - {required this.message, required this.code}); - - final String code; - final String message; - - factory DeliveryUpdateResponseDTO.fromJson(Map json) => - _$DeliveryUpdateResponseDTOFromJson(json); - - Map toJson() => _$DeliveryUpdateResponseDTOToJson(this); -} diff --git a/lib/dto/delivery_update_response.g.dart b/lib/dto/delivery_update_response.g.dart deleted file mode 100644 index b5c7823..0000000 --- a/lib/dto/delivery_update_response.g.dart +++ /dev/null @@ -1,18 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'delivery_update_response.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -DeliveryUpdateResponseDTO _$DeliveryUpdateResponseDTOFromJson( - Map json, -) => DeliveryUpdateResponseDTO( - message: json['message'] as String, - code: json['code'] as String, -); - -Map _$DeliveryUpdateResponseDTOToJson( - DeliveryUpdateResponseDTO instance, -) => {'code': instance.code, 'message': instance.message}; diff --git a/lib/dto/discount.dart b/lib/dto/discount.dart deleted file mode 100644 index 9e8fd1b..0000000 --- a/lib/dto/discount.dart +++ /dev/null @@ -1,15 +0,0 @@ -import 'article.dart'; -import 'package:json_annotation/json_annotation.dart'; - -part 'discount.g.dart'; - -@JsonSerializable(fieldRename: FieldRename.snake) -class DiscountDTO { - DiscountDTO({required this.note, required this.noteId, required this.article}); - String? note; - String? noteId; - ArticleDTO article; - - factory DiscountDTO.fromJson(Map json) => _$DiscountDTOFromJson(json); - Map toJson() => _$DiscountDTOToJson(this); -} \ No newline at end of file diff --git a/lib/dto/discount.g.dart b/lib/dto/discount.g.dart deleted file mode 100644 index faf5558..0000000 --- a/lib/dto/discount.g.dart +++ /dev/null @@ -1,20 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'discount.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -DiscountDTO _$DiscountDTOFromJson(Map json) => DiscountDTO( - note: json['note'] as String?, - noteId: json['note_id'] as String?, - article: ArticleDTO.fromJson(json['article'] as Map), -); - -Map _$DiscountDTOToJson(DiscountDTO instance) => - { - 'note': instance.note, - 'note_id': instance.noteId, - 'article': instance.article, - }; diff --git a/lib/dto/discount_add.dart b/lib/dto/discount_add.dart deleted file mode 100644 index ea07328..0000000 --- a/lib/dto/discount_add.dart +++ /dev/null @@ -1,18 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; - -part 'discount_add.g.dart'; - -@JsonSerializable(fieldRename: FieldRename.snake) -class DiscountAddDTO { - DiscountAddDTO( - {required this.note, required this.deliveryId, required this.discount}); - - String note; - String deliveryId; - int discount; - - factory DiscountAddDTO.fromJson(Map json) => - _$DiscountAddDTOFromJson(json); - - Map toJson() => _$DiscountAddDTOToJson(this); -} diff --git a/lib/dto/discount_add.g.dart b/lib/dto/discount_add.g.dart deleted file mode 100644 index 128f9d1..0000000 --- a/lib/dto/discount_add.g.dart +++ /dev/null @@ -1,21 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'discount_add.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -DiscountAddDTO _$DiscountAddDTOFromJson(Map json) => - DiscountAddDTO( - note: json['note'] as String, - deliveryId: json['delivery_id'] as String, - discount: (json['discount'] as num).toInt(), - ); - -Map _$DiscountAddDTOToJson(DiscountAddDTO instance) => - { - 'note': instance.note, - 'delivery_id': instance.deliveryId, - 'discount': instance.discount, - }; diff --git a/lib/dto/discount_add_response.dart b/lib/dto/discount_add_response.dart deleted file mode 100644 index f0535d5..0000000 --- a/lib/dto/discount_add_response.dart +++ /dev/null @@ -1,64 +0,0 @@ -import 'article.dart'; -import 'basic_response.dart'; -import 'package:json_annotation/json_annotation.dart'; - -part 'discount_add_response.g.dart'; - -@JsonSerializable(fieldRename: FieldRename.snake) -class PriceInformation { - PriceInformation({required this.net, required this.gross}); - - double net; - double gross; - - factory PriceInformation.fromJson(Map json) => - _$PriceInformationFromJson(json); - - Map toJson() => _$PriceInformationToJson(this); -} - -@JsonSerializable(fieldRename: FieldRename.snake) -class NoteInformation { - NoteInformation({required this.rowId, required this.noteDescription}); - - String rowId; - String noteDescription; - - factory NoteInformation.fromJson(Map json) => - _$NoteInformationFromJson(json); - - Map toJson() => _$NoteInformationToJson(this); -} - -@JsonSerializable(fieldRename: FieldRename.snake) -class UpdatedValues { - UpdatedValues( - {required this.discount, - required this.receipt, - required this.article, - required this.note}); - - PriceInformation discount; - PriceInformation receipt; - NoteInformation note; - ArticleDTO article; - - factory UpdatedValues.fromJson(Map json) => - _$UpdatedValuesFromJson(json); - - Map toJson() => _$UpdatedValuesToJson(this); -} - -@JsonSerializable(fieldRename: FieldRename.snake) -class DiscountAddResponseDTO extends BasicResponseDTO { - DiscountAddResponseDTO( - {required this.values, required super.succeeded, required super.message}); - - UpdatedValues values; - - factory DiscountAddResponseDTO.fromJson(Map json) => - _$DiscountAddResponseDTOFromJson(json); - - @override - Map toJson() => _$DiscountAddResponseDTOToJson(this); -} diff --git a/lib/dto/discount_add_response.g.dart b/lib/dto/discount_add_response.g.dart deleted file mode 100644 index 470196b..0000000 --- a/lib/dto/discount_add_response.g.dart +++ /dev/null @@ -1,61 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'discount_add_response.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -PriceInformation _$PriceInformationFromJson(Map json) => - PriceInformation( - net: (json['net'] as num).toDouble(), - gross: (json['gross'] as num).toDouble(), - ); - -Map _$PriceInformationToJson(PriceInformation instance) => - {'net': instance.net, 'gross': instance.gross}; - -NoteInformation _$NoteInformationFromJson(Map json) => - NoteInformation( - rowId: json['row_id'] as String, - noteDescription: json['note_description'] as String, - ); - -Map _$NoteInformationToJson(NoteInformation instance) => - { - 'row_id': instance.rowId, - 'note_description': instance.noteDescription, - }; - -UpdatedValues _$UpdatedValuesFromJson( - Map json, -) => UpdatedValues( - discount: PriceInformation.fromJson(json['discount'] as Map), - receipt: PriceInformation.fromJson(json['receipt'] as Map), - article: ArticleDTO.fromJson(json['article'] as Map), - note: NoteInformation.fromJson(json['note'] as Map), -); - -Map _$UpdatedValuesToJson(UpdatedValues instance) => - { - 'discount': instance.discount, - 'receipt': instance.receipt, - 'note': instance.note, - 'article': instance.article, - }; - -DiscountAddResponseDTO _$DiscountAddResponseDTOFromJson( - Map json, -) => DiscountAddResponseDTO( - values: UpdatedValues.fromJson(json['values'] as Map), - succeeded: json['succeeded'] as bool, - message: json['message'] as String, -); - -Map _$DiscountAddResponseDTOToJson( - DiscountAddResponseDTO instance, -) => { - 'succeeded': instance.succeeded, - 'message': instance.message, - 'values': instance.values, -}; diff --git a/lib/dto/discount_remove.dart b/lib/dto/discount_remove.dart deleted file mode 100644 index 7ca17db..0000000 --- a/lib/dto/discount_remove.dart +++ /dev/null @@ -1,15 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; - -part 'discount_remove.g.dart'; - -@JsonSerializable(fieldRename: FieldRename.snake) -class DiscountRemoveDTO { - DiscountRemoveDTO( - {required this.deliveryId}); - String deliveryId; - - factory DiscountRemoveDTO.fromJson(Map json) => - _$DiscountRemoveDTOFromJson(json); - - Map toJson() => _$DiscountRemoveDTOToJson(this); -} diff --git a/lib/dto/discount_remove.g.dart b/lib/dto/discount_remove.g.dart deleted file mode 100644 index d4692d9..0000000 --- a/lib/dto/discount_remove.g.dart +++ /dev/null @@ -1,13 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'discount_remove.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -DiscountRemoveDTO _$DiscountRemoveDTOFromJson(Map json) => - DiscountRemoveDTO(deliveryId: json['delivery_id'] as String); - -Map _$DiscountRemoveDTOToJson(DiscountRemoveDTO instance) => - {'delivery_id': instance.deliveryId}; diff --git a/lib/dto/discount_remove_response.dart b/lib/dto/discount_remove_response.dart deleted file mode 100644 index 6b94347..0000000 --- a/lib/dto/discount_remove_response.dart +++ /dev/null @@ -1,23 +0,0 @@ -import 'basic_response.dart'; -import 'package:json_annotation/json_annotation.dart'; - -import 'discount_add_response.dart'; - -part 'discount_remove_response.g.dart'; - -@JsonSerializable(fieldRename: FieldRename.snake) -class DiscountRemoveResponseDTO extends BasicResponseDTO { - DiscountRemoveResponseDTO( - { - required this.receipt, - required super.succeeded, - required super.message}); - - PriceInformation receipt; - - factory DiscountRemoveResponseDTO.fromJson(Map json) => - _$DiscountRemoveResponseDTOFromJson(json); - - @override - Map toJson() => _$DiscountRemoveResponseDTOToJson(this); -} \ No newline at end of file diff --git a/lib/dto/discount_remove_response.g.dart b/lib/dto/discount_remove_response.g.dart deleted file mode 100644 index 6528bf8..0000000 --- a/lib/dto/discount_remove_response.g.dart +++ /dev/null @@ -1,23 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'discount_remove_response.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -DiscountRemoveResponseDTO _$DiscountRemoveResponseDTOFromJson( - Map json, -) => DiscountRemoveResponseDTO( - receipt: PriceInformation.fromJson(json['receipt'] as Map), - succeeded: json['succeeded'] as bool, - message: json['message'] as String, -); - -Map _$DiscountRemoveResponseDTOToJson( - DiscountRemoveResponseDTO instance, -) => { - 'succeeded': instance.succeeded, - 'message': instance.message, - 'receipt': instance.receipt, -}; diff --git a/lib/dto/discount_update.dart b/lib/dto/discount_update.dart deleted file mode 100644 index 5b536ba..0000000 --- a/lib/dto/discount_update.dart +++ /dev/null @@ -1,18 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; - -part 'discount_update.g.dart'; - -@JsonSerializable(fieldRename: FieldRename.snake) -class DiscountUpdateDTO { - DiscountUpdateDTO( - {required this.note, required this.deliveryId, required this.discount}); - - String? note; - String deliveryId; - int? discount; - - factory DiscountUpdateDTO.fromJson(Map json) => - _$DiscountUpdateDTOFromJson(json); - - Map toJson() => _$DiscountUpdateDTOToJson(this); -} diff --git a/lib/dto/discount_update.g.dart b/lib/dto/discount_update.g.dart deleted file mode 100644 index 827ec78..0000000 --- a/lib/dto/discount_update.g.dart +++ /dev/null @@ -1,21 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'discount_update.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -DiscountUpdateDTO _$DiscountUpdateDTOFromJson(Map json) => - DiscountUpdateDTO( - note: json['note'] as String?, - deliveryId: json['delivery_id'] as String, - discount: (json['discount'] as num?)?.toInt(), - ); - -Map _$DiscountUpdateDTOToJson(DiscountUpdateDTO instance) => - { - 'note': instance.note, - 'delivery_id': instance.deliveryId, - 'discount': instance.discount, - }; diff --git a/lib/dto/discount_update_response.dart b/lib/dto/discount_update_response.dart deleted file mode 100644 index e0a2c7d..0000000 --- a/lib/dto/discount_update_response.dart +++ /dev/null @@ -1,22 +0,0 @@ - -import 'basic_response.dart'; -import 'discount_add_response.dart'; -import 'package:json_annotation/json_annotation.dart'; - -part 'discount_update_response.g.dart'; - -@JsonSerializable(fieldRename: FieldRename.snake) -class DiscountUpdateResponseDTO extends BasicResponseDTO { - DiscountUpdateResponseDTO( - {required this.values, - required super.succeeded, - required super.message}); - - UpdatedValues? values; - - factory DiscountUpdateResponseDTO.fromJson(Map json) => - _$DiscountUpdateResponseDTOFromJson(json); - - @override - Map toJson() => _$DiscountUpdateResponseDTOToJson(this); -} diff --git a/lib/dto/discount_update_response.g.dart b/lib/dto/discount_update_response.g.dart deleted file mode 100644 index a7e5543..0000000 --- a/lib/dto/discount_update_response.g.dart +++ /dev/null @@ -1,26 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'discount_update_response.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -DiscountUpdateResponseDTO _$DiscountUpdateResponseDTOFromJson( - Map json, -) => DiscountUpdateResponseDTO( - values: - json['values'] == null - ? null - : UpdatedValues.fromJson(json['values'] as Map), - succeeded: json['succeeded'] as bool, - message: json['message'] as String, -); - -Map _$DiscountUpdateResponseDTOToJson( - DiscountUpdateResponseDTO instance, -) => { - 'succeeded': instance.succeeded, - 'message': instance.message, - 'values': instance.values, -}; diff --git a/lib/dto/driver.dart b/lib/dto/driver.dart deleted file mode 100644 index d562489..0000000 --- a/lib/dto/driver.dart +++ /dev/null @@ -1,16 +0,0 @@ -import 'car.dart'; -import 'package:json_annotation/json_annotation.dart'; - -part 'driver.g.dart'; - -@JsonSerializable(fieldRename: FieldRename.snake) -class DriverDTO { - DriverDTO({required this.id, required this.name, required this.salutation, required this.cars}); - String id; - String name; - String salutation; - List cars; - - factory DriverDTO.fromJson(Map json) => _$DriverDTOFromJson(json); - Map toJson() => _$DriverDTOToJson(this); -} \ No newline at end of file diff --git a/lib/dto/driver.g.dart b/lib/dto/driver.g.dart deleted file mode 100644 index 29fd322..0000000 --- a/lib/dto/driver.g.dart +++ /dev/null @@ -1,24 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'driver.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -DriverDTO _$DriverDTOFromJson(Map json) => DriverDTO( - id: json['id'] as String, - name: json['name'] as String, - salutation: json['salutation'] as String, - cars: - (json['cars'] as List) - .map((e) => CarDTO.fromJson(e as Map)) - .toList(), -); - -Map _$DriverDTOToJson(DriverDTO instance) => { - 'id': instance.id, - 'name': instance.name, - 'salutation': instance.salutation, - 'cars': instance.cars, -}; diff --git a/lib/dto/image.dart b/lib/dto/image.dart deleted file mode 100644 index 9db9e3d..0000000 --- a/lib/dto/image.dart +++ /dev/null @@ -1,18 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; - -part 'image.g.dart'; - -@JsonSerializable(fieldRename: FieldRename.snake) -class ImageDTO { - ImageDTO( - {required this.url, required this.name, required this.oid}); - - String url; - String name; - String oid; - - factory ImageDTO.fromJson(Map json) => - _$ImageDTOFromJson(json); - - Map toJson() => _$ImageDTOToJson(this); -} \ No newline at end of file diff --git a/lib/dto/image.g.dart b/lib/dto/image.g.dart deleted file mode 100644 index 1025e20..0000000 --- a/lib/dto/image.g.dart +++ /dev/null @@ -1,19 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'image.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -ImageDTO _$ImageDTOFromJson(Map json) => ImageDTO( - url: json['url'] as String, - name: json['name'] as String, - oid: json['oid'] as String, -); - -Map _$ImageDTOToJson(ImageDTO instance) => { - 'url': instance.url, - 'name': instance.name, - 'oid': instance.oid, -}; diff --git a/lib/dto/image_note_response.dart b/lib/dto/image_note_response.dart deleted file mode 100644 index 77d0a1b..0000000 --- a/lib/dto/image_note_response.dart +++ /dev/null @@ -1,17 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; - -part 'image_note_response.g.dart'; - -@JsonSerializable(fieldRename: FieldRename.snake) -class ImageNoteDTO { - final String url; - final String oid; - final String name; - - ImageNoteDTO({required this.url, required this.oid, required this.name}); - - factory ImageNoteDTO.fromJson(Map json) => - _$ImageNoteDTOFromJson(json); - - Map toJson() => _$ImageNoteDTOToJson(this); -} diff --git a/lib/dto/image_note_response.g.dart b/lib/dto/image_note_response.g.dart deleted file mode 100644 index 594c093..0000000 --- a/lib/dto/image_note_response.g.dart +++ /dev/null @@ -1,20 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'image_note_response.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -ImageNoteDTO _$ImageNoteDTOFromJson(Map json) => ImageNoteDTO( - url: json['url'] as String, - oid: json['oid'] as String, - name: json['name'] as String, -); - -Map _$ImageNoteDTOToJson(ImageNoteDTO instance) => - { - 'url': instance.url, - 'oid': instance.oid, - 'name': instance.name, - }; diff --git a/lib/dto/note.dart b/lib/dto/note.dart deleted file mode 100644 index f68d96b..0000000 --- a/lib/dto/note.dart +++ /dev/null @@ -1,16 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; - -part 'note.g.dart'; - -@JsonSerializable(fieldRename: FieldRename.snake) -class NoteDTO { - NoteDTO( - {required this.id, - required this.note}); - - final String id; - final String note; - - factory NoteDTO.fromJson(Map json) => _$NoteDTOFromJson(json); - Map toJson() => _$NoteDTOToJson(this); -} diff --git a/lib/dto/note.g.dart b/lib/dto/note.g.dart deleted file mode 100644 index 11c47b3..0000000 --- a/lib/dto/note.g.dart +++ /dev/null @@ -1,15 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'note.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -NoteDTO _$NoteDTOFromJson(Map json) => - NoteDTO(id: json['id'] as String, note: json['note'] as String); - -Map _$NoteDTOToJson(NoteDTO instance) => { - 'id': instance.id, - 'note': instance.note, -}; diff --git a/lib/dto/note_add_response.dart b/lib/dto/note_add_response.dart deleted file mode 100644 index 0e09cc4..0000000 --- a/lib/dto/note_add_response.dart +++ /dev/null @@ -1,17 +0,0 @@ -import 'package:hl_lieferservice/dto/basic_response.dart'; -import 'package:hl_lieferservice/dto/note.dart'; -import 'package:json_annotation/json_annotation.dart'; - -part 'note_add_response.g.dart'; - -@JsonSerializable(fieldRename: FieldRename.snake) -class NoteAddResponseDTO extends BasicResponseDTO { - NoteAddResponseDTO( - {required this.note, required super.succeeded, required super.message}); - - final NoteDTO? note; - - factory NoteAddResponseDTO.fromJson(Map json) => _$NoteAddResponseDTOFromJson(json); - @override - Map toJson() => _$NoteAddResponseDTOToJson(this); -} \ No newline at end of file diff --git a/lib/dto/note_add_response.g.dart b/lib/dto/note_add_response.g.dart deleted file mode 100644 index af068d1..0000000 --- a/lib/dto/note_add_response.g.dart +++ /dev/null @@ -1,24 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'note_add_response.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -NoteAddResponseDTO _$NoteAddResponseDTOFromJson(Map json) => - NoteAddResponseDTO( - note: - json['note'] == null - ? null - : NoteDTO.fromJson(json['note'] as Map), - succeeded: json['succeeded'] as bool, - message: json['message'] as String, - ); - -Map _$NoteAddResponseDTOToJson(NoteAddResponseDTO instance) => - { - 'succeeded': instance.succeeded, - 'message': instance.message, - 'note': instance.note, - }; diff --git a/lib/dto/note_get_response.dart b/lib/dto/note_get_response.dart deleted file mode 100644 index 6181b65..0000000 --- a/lib/dto/note_get_response.dart +++ /dev/null @@ -1,22 +0,0 @@ -import 'package:hl_lieferservice/dto/image_note_response.dart'; - -import 'note.dart'; -import 'package:json_annotation/json_annotation.dart'; - -part 'note_get_response.g.dart'; - -@JsonSerializable(fieldRename: FieldRename.snake) -class NoteGetResponseDTO { - NoteGetResponseDTO( - {required this.notes, required this.succeeded, required this.message, required this.images}); - - final List notes; - final List images; - final bool succeeded; - final String message; - - factory NoteGetResponseDTO.fromJson(Map json) => - _$NoteGetResponseDTOFromJson(json); - - Map toJson() => _$NoteGetResponseDTOToJson(this); -} diff --git a/lib/dto/note_get_response.g.dart b/lib/dto/note_get_response.g.dart deleted file mode 100644 index 7e81213..0000000 --- a/lib/dto/note_get_response.g.dart +++ /dev/null @@ -1,29 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'note_get_response.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -NoteGetResponseDTO _$NoteGetResponseDTOFromJson(Map json) => - NoteGetResponseDTO( - notes: - (json['notes'] as List) - .map((e) => NoteDTO.fromJson(e as Map)) - .toList(), - succeeded: json['succeeded'] as bool, - message: json['message'] as String, - images: - (json['images'] as List) - .map((e) => ImageNoteDTO.fromJson(e as Map)) - .toList(), - ); - -Map _$NoteGetResponseDTOToJson(NoteGetResponseDTO instance) => - { - 'notes': instance.notes, - 'images': instance.images, - 'succeeded': instance.succeeded, - 'message': instance.message, - }; diff --git a/lib/dto/note_template.dart b/lib/dto/note_template.dart deleted file mode 100644 index 24f96d9..0000000 --- a/lib/dto/note_template.dart +++ /dev/null @@ -1,18 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; - -part 'note_template.g.dart'; - -@JsonSerializable(fieldRename: FieldRename.snake) -class NoteTemplateDTO { - NoteTemplateDTO( - {required this.language, - required this.title, - required this.note}); - - final String note; - final String language; - final String title; - - factory NoteTemplateDTO.fromJson(Map json) => _$NoteTemplateDTOFromJson(json); - Map toJson() => _$NoteTemplateDTOToJson(this); -} diff --git a/lib/dto/note_template.g.dart b/lib/dto/note_template.g.dart deleted file mode 100644 index de522a0..0000000 --- a/lib/dto/note_template.g.dart +++ /dev/null @@ -1,21 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'note_template.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -NoteTemplateDTO _$NoteTemplateDTOFromJson(Map json) => - NoteTemplateDTO( - language: json['language'] as String, - title: json['title'] as String, - note: json['note'] as String, - ); - -Map _$NoteTemplateDTOToJson(NoteTemplateDTO instance) => - { - 'note': instance.note, - 'language': instance.language, - 'title': instance.title, - }; diff --git a/lib/dto/note_template_response.dart b/lib/dto/note_template_response.dart deleted file mode 100644 index 03dd93d..0000000 --- a/lib/dto/note_template_response.dart +++ /dev/null @@ -1,19 +0,0 @@ -import 'note_template.dart'; -import 'package:json_annotation/json_annotation.dart'; - -part 'note_template_response.g.dart'; - -@JsonSerializable(fieldRename: FieldRename.snake) -class NoteTemplateResponseDTO { - NoteTemplateResponseDTO( - {required this.notes, required this.succeeded, required this.message}); - - final List notes; - final bool succeeded; - final String message; - - factory NoteTemplateResponseDTO.fromJson(Map json) => - _$NoteTemplateResponseDTOFromJson(json); - - Map toJson() => _$NoteTemplateResponseDTOToJson(this); -} diff --git a/lib/dto/note_template_response.g.dart b/lib/dto/note_template_response.g.dart deleted file mode 100644 index 0660885..0000000 --- a/lib/dto/note_template_response.g.dart +++ /dev/null @@ -1,26 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'note_template_response.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -NoteTemplateResponseDTO _$NoteTemplateResponseDTOFromJson( - Map json, -) => NoteTemplateResponseDTO( - notes: - (json['notes'] as List) - .map((e) => NoteTemplateDTO.fromJson(e as Map)) - .toList(), - succeeded: json['succeeded'] as bool, - message: json['message'] as String, -); - -Map _$NoteTemplateResponseDTOToJson( - NoteTemplateResponseDTO instance, -) => { - 'notes': instance.notes, - 'succeeded': instance.succeeded, - 'message': instance.message, -}; diff --git a/lib/dto/payment.dart b/lib/dto/payment.dart deleted file mode 100644 index af3587c..0000000 --- a/lib/dto/payment.dart +++ /dev/null @@ -1,17 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; - -part 'payment.g.dart'; - -@JsonSerializable() -class PaymentMethodDTO { - PaymentMethodDTO(this.id, - {required this.description, - required this.shortCode}); - - final String id; - final String description; - final String shortCode; - - factory PaymentMethodDTO.fromJson(Map json) => _$PaymentMethodDTOFromJson(json); - Map toJson() => _$PaymentMethodDTOToJson(this); -} \ No newline at end of file diff --git a/lib/dto/payment.g.dart b/lib/dto/payment.g.dart deleted file mode 100644 index d55c729..0000000 --- a/lib/dto/payment.g.dart +++ /dev/null @@ -1,21 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'payment.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -PaymentMethodDTO _$PaymentMethodDTOFromJson(Map json) => - PaymentMethodDTO( - json['id'] as String, - description: json['description'] as String, - shortCode: json['shortCode'] as String, - ); - -Map _$PaymentMethodDTOToJson(PaymentMethodDTO instance) => - { - 'id': instance.id, - 'description': instance.description, - 'shortCode': instance.shortCode, - }; diff --git a/lib/dto/payments.dart b/lib/dto/payments.dart deleted file mode 100644 index 1e00154..0000000 --- a/lib/dto/payments.dart +++ /dev/null @@ -1,14 +0,0 @@ -import 'payment.dart'; -import 'package:json_annotation/json_annotation.dart'; - -part 'payments.g.dart'; - -@JsonSerializable() -class PaymentMethodListDTO { - PaymentMethodListDTO({required this.paymentMethods}); - - final List paymentMethods; - - factory PaymentMethodListDTO.fromJson(Map json) => _$PaymentMethodListDTOFromJson(json); - Map toJson() => _$PaymentMethodListDTOToJson(this); -} \ No newline at end of file diff --git a/lib/dto/payments.g.dart b/lib/dto/payments.g.dart deleted file mode 100644 index 0a03b28..0000000 --- a/lib/dto/payments.g.dart +++ /dev/null @@ -1,20 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'payments.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -PaymentMethodListDTO _$PaymentMethodListDTOFromJson( - Map json, -) => PaymentMethodListDTO( - paymentMethods: - (json['paymentMethods'] as List) - .map((e) => PaymentMethodDTO.fromJson(e as Map)) - .toList(), -); - -Map _$PaymentMethodListDTOToJson( - PaymentMethodListDTO instance, -) => {'paymentMethods': instance.paymentMethods}; diff --git a/lib/dto/scan.dart b/lib/dto/scan.dart deleted file mode 100644 index 442a736..0000000 --- a/lib/dto/scan.dart +++ /dev/null @@ -1,15 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; - -part 'scan.g.dart'; - -@JsonSerializable(fieldRename: FieldRename.snake) -class ScanDTO { - ScanDTO({required this.internalId}); - - String internalId; - - factory ScanDTO.fromJson(Map json) => - _$ScanDTOFromJson(json); - - Map toJson() => _$ScanDTOToJson(this); -} diff --git a/lib/dto/scan.g.dart b/lib/dto/scan.g.dart deleted file mode 100644 index 5f1275b..0000000 --- a/lib/dto/scan.g.dart +++ /dev/null @@ -1,14 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'scan.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -ScanDTO _$ScanDTOFromJson(Map json) => - ScanDTO(internalId: json['internal_id'] as String); - -Map _$ScanDTOToJson(ScanDTO instance) => { - 'internal_id': instance.internalId, -}; diff --git a/lib/dto/scan_response.dart b/lib/dto/scan_response.dart deleted file mode 100644 index e4c52c9..0000000 --- a/lib/dto/scan_response.dart +++ /dev/null @@ -1,18 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; - -part 'scan_response.g.dart'; - -@JsonSerializable(fieldRename: FieldRename.snake) -class ScanResponseDTO { - ScanResponseDTO( - {required this.message, required this.succeeded, required this.noteId}); - - final bool succeeded; - final String message; - final String? noteId; - - factory ScanResponseDTO.fromJson(Map json) => - _$ScanResponseDTOFromJson(json); - - Map toJson() => _$ScanResponseDTOToJson(this); -} diff --git a/lib/dto/scan_response.g.dart b/lib/dto/scan_response.g.dart deleted file mode 100644 index a2f9f9a..0000000 --- a/lib/dto/scan_response.g.dart +++ /dev/null @@ -1,21 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'scan_response.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -ScanResponseDTO _$ScanResponseDTOFromJson(Map json) => - ScanResponseDTO( - message: json['message'] as String, - succeeded: json['succeeded'] as bool, - noteId: json['note_id'] as String?, - ); - -Map _$ScanResponseDTOToJson(ScanResponseDTO instance) => - { - 'succeeded': instance.succeeded, - 'message': instance.message, - 'note_id': instance.noteId, - }; diff --git a/lib/dto/set_article_amount_request.dart b/lib/dto/set_article_amount_request.dart deleted file mode 100644 index ebf3f61..0000000 --- a/lib/dto/set_article_amount_request.dart +++ /dev/null @@ -1,23 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; - -part 'set_article_amount_request.g.dart'; - -@JsonSerializable(fieldRename: FieldRename.snake) -class SetArticleAmountRequestDTO { - SetArticleAmountRequestDTO({ - required this.articleId, - required this.deliveryId, - required this.amount, - this.reason - }); - - String deliveryId; - int amount; - String articleId; - String? reason; - - factory SetArticleAmountRequestDTO.fromJson(Map json) => - _$SetArticleAmountRequestDTOFromJson(json); - - Map toJson() => _$SetArticleAmountRequestDTOToJson(this); -} diff --git a/lib/dto/set_article_amount_request.g.dart b/lib/dto/set_article_amount_request.g.dart deleted file mode 100644 index 91ee5a7..0000000 --- a/lib/dto/set_article_amount_request.g.dart +++ /dev/null @@ -1,25 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'set_article_amount_request.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -SetArticleAmountRequestDTO _$SetArticleAmountRequestDTOFromJson( - Map json, -) => SetArticleAmountRequestDTO( - articleId: json['article_id'] as String, - deliveryId: json['delivery_id'] as String, - amount: (json['amount'] as num).toInt(), - reason: json['reason'] as String?, -); - -Map _$SetArticleAmountRequestDTOToJson( - SetArticleAmountRequestDTO instance, -) => { - 'delivery_id': instance.deliveryId, - 'amount': instance.amount, - 'article_id': instance.articleId, - 'reason': instance.reason, -}; diff --git a/lib/dto/set_article_amount_response.dart b/lib/dto/set_article_amount_response.dart deleted file mode 100644 index 05bc199..0000000 --- a/lib/dto/set_article_amount_response.dart +++ /dev/null @@ -1,21 +0,0 @@ -import 'package:hl_lieferservice/dto/basic_response.dart'; -import 'package:json_annotation/json_annotation.dart'; - -part 'set_article_amount_response.g.dart'; - -@JsonSerializable(fieldRename: FieldRename.snake) -class SetArticleAmountResponseDTO extends BasicResponseDTO { - SetArticleAmountResponseDTO({ - required super.succeeded, - required super.message, - this.noteId - }); - - String? noteId; - - factory SetArticleAmountResponseDTO.fromJson(Map json) => - _$SetArticleAmountResponseDTOFromJson(json); - - @override - Map toJson() => _$SetArticleAmountResponseDTOToJson(this); -} diff --git a/lib/dto/set_article_amount_response.g.dart b/lib/dto/set_article_amount_response.g.dart deleted file mode 100644 index 945e4ea..0000000 --- a/lib/dto/set_article_amount_response.g.dart +++ /dev/null @@ -1,23 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'set_article_amount_response.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -SetArticleAmountResponseDTO _$SetArticleAmountResponseDTOFromJson( - Map json, -) => SetArticleAmountResponseDTO( - succeeded: json['succeeded'] as bool, - message: json['message'] as String, - noteId: json['note_id'] as String?, -); - -Map _$SetArticleAmountResponseDTOToJson( - SetArticleAmountResponseDTO instance, -) => { - 'succeeded': instance.succeeded, - 'message': instance.message, - 'note_id': instance.noteId, -}; diff --git a/lib/exceptions.dart b/lib/exceptions.dart deleted file mode 100644 index 9140a6b..0000000 --- a/lib/exceptions.dart +++ /dev/null @@ -1 +0,0 @@ -class AppConfigNotFound implements Exception {} \ No newline at end of file diff --git a/lib/feature/authentication/bloc/auth_bloc.dart b/lib/feature/authentication/bloc/auth_bloc.dart index 0d8d4a1..ebdd1e6 100644 --- a/lib/feature/authentication/bloc/auth_bloc.dart +++ b/lib/feature/authentication/bloc/auth_bloc.dart @@ -81,7 +81,16 @@ class AuthBloc extends Bloc { Emitter emit, ) async { try { - final restored = await tokenProvider.restoreSession(); + // Timeout-Schutz: hängt der Restore (z. B. nativer flutter_appauth- + // Token-Call nach Hot-Restart, nicht erreichbarer Issuer), darf der + // Bootstrap NICHT ewig im Splash bleiben. Nach dem Timeout fallen wir + // sauber auf die LoginPage zurück. Läuft der Restore später doch noch + // erfolgreich durch, kommt der Login via Stream-Event (AuthLoggedIn) + // nachträglich an und der State wird zu Authenticated. + final restored = await tokenProvider.restoreSession().timeout( + const Duration(seconds: 15), + onTimeout: () => false, + ); if (!restored) { // Kein gespeicherter Refresh-Token oder Refresh fehlgeschlagen: // Vom Splash zur LoginPage übergehen. Kein Snackbar — das ist diff --git a/lib/feature/car_selection/bloc/bloc.dart b/lib/feature/car_selection/bloc/bloc.dart index 3196aff..c027e32 100644 --- a/lib/feature/car_selection/bloc/bloc.dart +++ b/lib/feature/car_selection/bloc/bloc.dart @@ -2,7 +2,7 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:hl_lieferservice/feature/car_selection/repository/car_selection_repository.dart'; import 'package:hl_lieferservice/feature/cars/model/selection.dart'; -import 'package:hl_lieferservice/model/car.dart'; +import 'package:hl_lieferservice/domain/entity/car.dart'; import 'events.dart'; import 'state.dart'; diff --git a/lib/feature/car_selection/bloc/events.dart b/lib/feature/car_selection/bloc/events.dart index 8b7e9d6..ca70bd0 100644 --- a/lib/feature/car_selection/bloc/events.dart +++ b/lib/feature/car_selection/bloc/events.dart @@ -1,4 +1,4 @@ -import 'package:hl_lieferservice/model/car.dart'; +import 'package:hl_lieferservice/domain/entity/car.dart'; abstract class CarSelectEvent {} diff --git a/lib/feature/car_selection/bloc/state.dart b/lib/feature/car_selection/bloc/state.dart index dc478da..7875125 100644 --- a/lib/feature/car_selection/bloc/state.dart +++ b/lib/feature/car_selection/bloc/state.dart @@ -1,4 +1,4 @@ -import 'package:hl_lieferservice/model/car.dart'; +import 'package:hl_lieferservice/domain/entity/car.dart'; abstract class CarSelectState {} diff --git a/lib/feature/car_selection/presentation/car_selection_card.dart b/lib/feature/car_selection/presentation/car_selection_card.dart index 7c87924..7b86cbf 100644 --- a/lib/feature/car_selection/presentation/car_selection_card.dart +++ b/lib/feature/car_selection/presentation/car_selection_card.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:hl_lieferservice/model/car.dart'; +import 'package:hl_lieferservice/domain/entity/car.dart'; class CarSelectionCard extends StatelessWidget { final Car car; diff --git a/lib/feature/car_selection/presentation/car_selection_page.dart b/lib/feature/car_selection/presentation/car_selection_page.dart index 319d6ea..8d70778 100644 --- a/lib/feature/car_selection/presentation/car_selection_page.dart +++ b/lib/feature/car_selection/presentation/car_selection_page.dart @@ -11,7 +11,7 @@ import 'package:hl_lieferservice/feature/cars/bloc/cars_bloc.dart'; import 'package:hl_lieferservice/feature/cars/bloc/cars_event.dart'; import 'package:hl_lieferservice/feature/cars/bloc/cars_state.dart'; import 'package:hl_lieferservice/feature/cars/presentation/car_dialog.dart'; -import 'package:hl_lieferservice/model/car.dart'; +import 'package:hl_lieferservice/domain/entity/car.dart'; class CarSelectionPage extends StatefulWidget { /// When set, the page is in "change" mode: the car is pre-highlighted diff --git a/lib/feature/cars/presentation/car_card.dart b/lib/feature/cars/presentation/car_card.dart index 73442e2..f0b012e 100644 --- a/lib/feature/cars/presentation/car_card.dart +++ b/lib/feature/cars/presentation/car_card.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; -import '../../../model/car.dart'; +import 'package:hl_lieferservice/domain/entity/car.dart'; import 'car_dialog.dart'; class CarCard extends StatelessWidget { diff --git a/lib/feature/delivery/bloc/phase_bloc.dart b/lib/feature/delivery/bloc/phase_bloc.dart index f68a4ca..f01de47 100644 --- a/lib/feature/delivery/bloc/phase_bloc.dart +++ b/lib/feature/delivery/bloc/phase_bloc.dart @@ -13,8 +13,17 @@ import 'package:hl_lieferservice/feature/delivery/overview/service/phase_service /// Default-Eintritt [DeliveryPhase.sortieren]. typedef CarCountResolver = int? Function(); +/// Liefert einen Token, der die aktuell geladene Tour-Version identifiziert +/// (typischerweise aus `Tour.syncedAt`). Der [PhaseService] bindet die +/// persistierten Phasen an diesen Token — ein neuer ERP-Sync / Demo-Seed +/// erzeugt einen neuen Token und damit einen frischen Phasen-Stand. +/// +/// Liefert `null`, wenn (noch) keine Tour geladen ist; der BLoC nutzt dann +/// einen neutralen Fallback-Token. +typedef TourTokenResolver = String? Function(); + /// Zentraler State für die aktuelle Phase je Fahrzeug. Persistiert über -/// [PhaseService] auf datumsbezogene SharedPreferences-Keys (siehe Service). +/// [PhaseService] auf tour-token-bezogene SharedPreferences-Keys (siehe Service). /// /// Eintrittsphase nach Fahrzeugauswahl: /// * 1 Auto im Team → [DeliveryPhase.sortieren] (bisheriges Verhalten). @@ -30,9 +39,14 @@ class PhaseBloc extends Bloc { /// Provider so verdrahtet, dass sie aus dem [TourBloc] kommt. final CarCountResolver? carCountResolver; + /// Liefert den Tour-Token (aus `Tour.syncedAt`). Bindet die persistierten + /// Phasen an die aktuelle Tour-Version. + final TourTokenResolver? tourTokenResolver; + PhaseBloc({ PhaseService? phaseService, this.carCountResolver, + this.tourTokenResolver, }) : phaseService = phaseService ?? PhaseService(), super(PhaseInitial()) { on(_load); @@ -40,6 +54,11 @@ class PhaseBloc extends Bloc { on(_set); } + /// Aktueller Tour-Token oder neutraler Fallback, falls noch keine Tour + /// geladen ist (z. B. `TourEmpty`) — dort ist die Phase ohnehin + /// bedeutungslos. + String _token() => tourTokenResolver?.call() ?? 'no-tour'; + PhaseReady _ensureReady() { final current = state; return current is PhaseReady @@ -62,8 +81,9 @@ class PhaseBloc extends Bloc { if (current.phaseByCar.containsKey(event.carId)) return; try { - final persisted = await phaseService.load(event.carId); - final persistedMax = await phaseService.loadMax(event.carId); + final token = _token(); + final persisted = await phaseService.load(event.carId, token); + final persistedMax = await phaseService.loadMax(event.carId, token); final phase = persisted ?? _entryPhase(); // Max ist mindestens die aktuelle Phase. Falls in der Persistenz ein // höherer Wert steht (Rücksprung), den nehmen. @@ -75,11 +95,11 @@ class PhaseBloc extends Bloc { if (persisted == null) { // Erste Phase nach Fahrzeugauswahl direkt persistieren, damit // ein Resume nach App-Neustart die Phase kennt. - await phaseService.save(event.carId, phase); - await phaseService.saveMax(event.carId, maxPhase); + await phaseService.save(event.carId, token, phase); + await phaseService.saveMax(event.carId, token, maxPhase); } else if (persistedMax == null) { // Migration: alte Tage ohne Max-Tracking → einmalig nachziehen. - await phaseService.saveMax(event.carId, maxPhase); + await phaseService.saveMax(event.carId, token, maxPhase); } add(PhaseLoaded( @@ -109,12 +129,13 @@ class PhaseBloc extends Bloc { final next = current.withPhase(event.carId, event.phase); emit(next); try { - await phaseService.save(event.carId, event.phase); + final token = _token(); + await phaseService.save(event.carId, token, event.phase); // withPhase hat das Max ggf. hochgezogen — persistieren, damit ein // Neustart die "höchste erreichte Phase" kennt. final newMax = next.maxPhaseFor(event.carId); if (newMax != null) { - await phaseService.saveMax(event.carId, newMax); + await phaseService.saveMax(event.carId, token, newMax); } } catch (e, st) { debugPrint("PhaseBloc._set: $e $st"); diff --git a/lib/feature/delivery/bloc/tour_bloc.dart b/lib/feature/delivery/bloc/tour_bloc.dart index e611871..4cb9baa 100644 --- a/lib/feature/delivery/bloc/tour_bloc.dart +++ b/lib/feature/delivery/bloc/tour_bloc.dart @@ -1,717 +1,1255 @@ import 'dart:async'; -import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:uuid/uuid.dart'; + +import 'package:hl_lieferservice/data/cache/attachment_cache.dart'; +import 'package:hl_lieferservice/domain/entity/delivery.dart'; +import 'package:hl_lieferservice/domain/entity/delivery_credit.dart'; +import 'package:hl_lieferservice/domain/entity/delivery_item.dart'; +import 'package:hl_lieferservice/domain/entity/delivery_note.dart'; +import 'package:hl_lieferservice/domain/entity/delivery_service_value.dart'; +import 'package:hl_lieferservice/domain/entity/scan_intent.dart'; +import 'package:hl_lieferservice/domain/entity/scan_progress.dart'; +import 'package:hl_lieferservice/domain/entity/tour_details.dart'; +import 'package:hl_lieferservice/domain/repository/tour_repository.dart'; import 'package:hl_lieferservice/feature/delivery/bloc/tour_event.dart'; import 'package:hl_lieferservice/feature/delivery/bloc/tour_state.dart'; -import 'package:hl_lieferservice/feature/delivery/repository/process_repository.dart'; -import 'package:hl_lieferservice/feature/delivery/repository/tour_repository.dart'; -import 'package:hl_lieferservice/feature/delivery/overview/service/distance_service.dart'; -import 'package:hl_lieferservice/feature/delivery/overview/service/reorder_service.dart'; -import 'package:hl_lieferservice/feature/delivery/util.dart'; -import 'package:hl_lieferservice/model/tour.dart'; -import 'package:hl_lieferservice/feature/authentication/bloc/auth_bloc.dart'; -import 'package:hl_lieferservice/feature/authentication/bloc/auth_event.dart'; -import 'package:hl_lieferservice/feature/authentication/exceptions.dart'; import 'package:hl_lieferservice/widget/operations/bloc/operation_bloc.dart'; import 'package:hl_lieferservice/widget/operations/bloc/operation_event.dart'; -import 'package:rxdart/rxdart.dart'; +/// Bloc für die heutige Tour. Phase C+D-2: nur Lese-Pfad + Reorder + +/// Car-Assign. Spätere Schreib-Aktionen (Scan/Hold/Cancel/Complete/Notes) +/// landen in einem späteren Schritt **hier** und nicht in einem +/// Parallel-Bloc — der Plan ist ein Bloc pro Aggregat, nicht pro Aktion. class TourBloc extends Bloc { - OperationBloc opBloc; - AuthBloc authBloc; - TourRepository tourRepository; - ProcessRepository processRepository; - StreamSubscription? _combinedSubscription; - TourBloc({ - required this.opBloc, - required this.authBloc, required this.tourRepository, - ProcessRepository? processRepository, - }) : processRepository = processRepository ?? ProcessRepository(), - super(TourInitial()) { - _combinedSubscription = CombineLatestStream.combine2( - tourRepository.tour, - tourRepository.paymentOptions, - (tour, payments) => {'tour': tour, 'payments': payments}, - ).listen((combined) { - final tour = combined['tour'] as Tour?; - final payments = combined['payments'] as List; + required this.opBloc, + required this.attachmentCache, + }) : super(const TourInitial()) { + on(_onLoad); + on(_onRefresh); + on(_onReorder); + on(_onAssignCar); + on(_onAssignCarBulk); + on(_onScanItem); + on(_onUnscanItem); + on(_onHoldItem); + on(_onUnholdItem); + on(_onRemoveItem); + on(_onUnremoveItem); + on(_onCancelDelivery); + on(_onHoldDelivery); + on(_onResumeDelivery); + on(_onCompleteDelivery); + on(_onAddDeliveryNote); + on(_onUpdateDeliveryNote); + on(_onDeleteDeliveryNote); + on(_onUploadDeliveryNoteImage); + on(_onSetDeliveryCredit); + on(_onRemoveDeliveryCredit); + on(_onSetDeliveryServiceValue); + on(_onRemoveDeliveryServiceValue); + } - if (tour == null) { + final TourRepository tourRepository; + final OperationBloc opBloc; + + /// Disk-Cache für Attachment-Vorschauen. Der Bloc nutzt ihn nur fürs + /// **Pruning**: bei jedem frischen Tour-Load wird der Cache auf die in der + /// Tour referenzierten Bild-IDs eingedampft, sodass Vorschauen gelöschter + /// Foto-Notizen lokal verschwinden. + final AttachmentCache attachmentCache; + + final Uuid _uuid = const Uuid(); + + /// Räumt verwaiste Cache-Bilder weg, sobald frische Tour-Daten vorliegen. + /// Fire-and-forget — Cache-Pflege darf den Load nie verzögern oder + /// scheitern lassen. + void _pruneAttachmentCache(TourDetails details) { + unawaited(attachmentCache.retainOnly(details.referencedAttachmentIds)); + } + + // ─── LoadTour ──────────────────────────────────────────────────────── + + Future _onLoad(LoadTour event, Emitter emit) async { + emit(const TourLoading()); + try { + final details = await tourRepository.getMyTourDetailsOfToday(); + if (details == null) { + emit(const TourEmpty()); return; } - - add(TourUpdated(tour: tour, payments: payments)); - }); - - on(_load); - on(_assignCar); - on(_unassignDelivery); - on(_increment); - on(_scan); - on(_scanComponent); - on(_holdDelivery); - on(_cancelDelivery); - on(_reactivateDelivery); - on(_unscan); - on(_resetAmount); - on(_addDiscount); - on(_removeDiscount); - on(_updateDiscount); - on(_updateDeliveryOptions); - on(_updatePayment); - on(_finishDelivery); - on(_updated); - on(_calculateDistances); - on(_requestSortingInformation); - on(_reorderDelivery); - on(_replaceSorting); - on(_confirmSorting); - on(_ensureSortingForCar); - on(_carsLoaded); - on(_setArticleAmount); - } - - @override - Future close() { - _combinedSubscription?.cancel(); - return super.close(); - } - - void _handleError(Object e, String fallbackMessage) { - if (e is UserUnauthorized) { - authBloc.add(SessionExpiredEvent()); - } else { - opBloc.add(FailOperation(message: fallbackMessage)); + emit(TourLoaded(details: details)); + _pruneAttachmentCache(details); + } catch (e, st) { + debugPrint('TourBloc.LoadTour fehlgeschlagen: $e\n$st'); + final message = _messageOf(e, 'Tour konnte nicht geladen werden'); + emit(TourLoadFailed(message: message)); + opBloc.add(FailOperation(message: message)); } } - void _setArticleAmount( - SetArticleAmountEvent event, - Emitter emit, - ) async { - final currentState = state; - if (currentState is TourLoaded) { - opBloc.add(StartOperation()); - try { - await tourRepository.setArticleAmount( - event.deliveryId, - event.articleId, - event.amount, - event.reason, - ); - opBloc.add(FinishOperation()); - } catch (e, st) { - debugPrint("$e $st"); - _handleError(e, "Fehler beim Ändern der Menge des Artikels"); - } - } - } + // ─── RefreshTour ───────────────────────────────────────────────────── - void _carsLoaded(CarsLoadedEvent event, Emitter emit) { - final currentState = state; - if (currentState is TourLoaded) { - currentState.tour.driver.cars = event.cars; - emit(currentState.copyWith()); - } - } - - void _reorderDelivery( - ReorderDeliveryEvent event, - Emitter emit, - ) async { - final currentState = state; - if (currentState is TourLoaded) { - Map> container = {...currentState.sortingInformation}; - - List reorderedList = reorderList( - container[event.carId.toString()] ?? [], - event.oldPosition, - event.newPosition, - ); - - container[event.carId.toString()] = reorderedList; - await ReorderService().saveSortingInformation(container); - - emit(currentState.copyWith(sortingInformation: container)); - } - } - - Future _ensureSortingForCar( - EnsureSortingForCarEvent event, - Emitter emit, - ) async { - final currentState = state; - if (currentState is! TourLoaded) return; - - // Ein-Auto-Teams: alle Tour-Lieferungen sind die eigenen — die - // Zuordnung erfolgt erst beim Scannen. - // Mehr-Auto-Teams: nach dem Auswahl-Schritt sind die Lieferungen - // bereits per `assignCar` mit der eigenen carId verknüpft; wir - // sortieren dann ausschließlich die eigenen. - final cars = currentState.tour.driver.cars; - final allTourIds = cars.length >= 2 - ? currentState.tour.deliveries - .where((d) => d.carId?.toString() == event.carId) - .map((d) => d.id) - .toList(growable: false) - : currentState.tour.deliveries - .map((d) => d.id) - .toList(growable: false); - final existing = - currentState.sortingInformation[event.carId] ?? const []; - - // Bestehende Reihenfolge beibehalten (nur Einträge, die noch in der Tour - // sind), dann fehlende Tour-Lieferungen hinten anhängen. - final allIdsSet = allTourIds.toSet(); - final seen = {}; - final merged = []; - for (final id in existing) { - if (allIdsSet.contains(id) && seen.add(id)) merged.add(id); - } - for (final id in allTourIds) { - if (seen.add(id)) merged.add(id); - } - - if (merged.length == existing.length && - _listEquals(merged, existing.toList())) { - // Nichts zu tun — Bucket ist bereits konsistent. + Future _onRefresh(RefreshTour event, Emitter emit) async { + final current = state; + if (current is! TourLoaded) { + // Keine sichtbare Tour zum „weichen" Refreshen — wie ein LoadTour + // behandeln. + await _onLoad(const LoadTour(), emit); return; } - final container = {...currentState.sortingInformation}; - container[event.carId] = merged; - - await ReorderService().saveSortingInformation(container); - emit(currentState.copyWith(sortingInformation: container)); - } - - bool _listEquals(List a, List b) { - if (a.length != b.length) return false; - for (int i = 0; i < a.length; i++) { - if (a[i] != b[i]) return false; - } - return true; - } - - Future _replaceSorting( - ReplaceSortingEvent event, - Emitter emit, - ) async { - final currentState = state; - if (currentState is TourLoaded) { - await ReorderService().saveSortingInformation( - event.newSortingInformation, - ); - emit( - currentState.copyWith( - sortingInformation: event.newSortingInformation, - ), - ); - } - } - - Future _confirmSorting( - ConfirmSortingEvent event, - Emitter emit, - ) async { - final currentState = state; - if (currentState is! TourLoaded) return; - - emit( - currentState.copyWith( - isPersistingSorting: true, - clearSortingPersistError: true, - ), - ); - - final orderedIds = - currentState.sortingInformation[event.carId] ?? const []; - + emit(current.copyWith(isRefreshing: true, refreshError: null)); try { - await processRepository.persistDeliveryOrder( - carId: event.carId, - orderedDeliveryIds: orderedIds, - ); - - // Hinweis: Der eigentliche Phasen-Wechsel auf `beladen` läuft über - // den [PhaseBloc], angestoßen vom UI-Listener in DeliverySortPage. - // So bleibt der Phasen-State zentral und der TourBloc unabhängig. - - // Re-read the latest state — earlier emits or upstream tour updates - // may have replaced it while the await was in flight. - final latest = state; - if (latest is TourLoaded) { - emit(latest.copyWith(isPersistingSorting: false)); - } - } catch (e, st) { - debugPrint("Fehler beim Persistieren der Sortierung: $e $st"); - final latest = state; - if (latest is TourLoaded) { - emit( - latest.copyWith( - isPersistingSorting: false, - sortingPersistError: - "Reihenfolge konnte nicht gespeichert werden. Bitte erneut versuchen.", - ), - ); - } - } - } - - void _calculateDistances( - RequestDeliveryDistanceEvent event, - Emitter emit, - ) async { - Map distances = {}; - - for (final delivery in event.tour.deliveries) { - try { - distances[delivery.id] = await DistanceService.getDistanceByRoad( - delivery.customer.address.toString(), - ); - } catch (e, st) { - debugPrint("Fehler beim Laden der Distanz: $e"); - debugPrint("$st"); - distances[delivery.id] = double.nan; - } - } - - final currentState = state; - if (currentState is TourLoaded) { - emit(currentState.copyWith(distances: distances)); - } - } - - void _requestSortingInformation( - RequestSortingInformationEvent event, - Emitter emit, - ) async { - Map> container = {}; - - try { - ReorderService service = ReorderService(); - - // Create empty default value if it does not exist yet - if (!service.orderInformationExist()) { - await service.initializeTour(event.tour); - } - - // Populate the container with information. If the file did not exist then it - // now contains the standard values. - container = await service.loadSortingInformation(); - - bool inconsistent = false; - for (final delivery in event.tour.deliveries) { - int info = container[delivery.carId.toString()]!.indexWhere( - (id) => id == delivery.id, - ); - - // not found, so add it to the list - if (info == -1) { - inconsistent = true; - container[delivery.carId.toString()]!.add(delivery.id); - } - } - - // if new deliveries were added then save the information with the newly - // populated container - if (inconsistent) { - await service.saveSortingInformation(container); - } - } catch (e, st) { - debugPrint("Fehler beim Lesen der Datei: $e"); - debugPrint("$st"); - - opBloc.add( - FailOperation( - message: - "Fehler beim Laden der Sortierung. Es wird ohne Sortierung fortgefahren", - ), - ); - - // fill the container without sorting information - for (final delivery in event.tour.deliveries) { - container[delivery.carId.toString()]!.add(delivery.id); - } - } - - emit( - TourLoaded( - tour: event.tour, - paymentOptions: event.payments, - sortingInformation: container, - ), - ); - - add(RequestDeliveryDistanceEvent(tour: event.tour)); - } - - void _updated(TourUpdated event, Emitter emit) { - final currentState = state; - final tour = event.tour.copyWith(); - final payments = - event.payments.map((payment) => payment.copyWith()).toList(); - - if (currentState is TourLoaded) { - emit( - TourLoaded( - tour: tour, - paymentOptions: payments, - distances: Map.from(currentState.distances ?? {}), - sortingInformation: currentState.sortingInformation, - pendingScanRequests: currentState.pendingScanRequests, - isPersistingSorting: currentState.isPersistingSorting, - sortingPersistError: currentState.sortingPersistError, - ), - ); - } - - if (currentState is TourLoading) { - add( - RequestSortingInformationEvent(tour: tour.copyWith(), payments: payments), - ); - } - } - - void _reactivateDelivery( - ReactivateDeliveryEvent event, - Emitter emit, - ) async { - final currentState = state; - if (currentState is TourLoaded) { - opBloc.add(StartOperation()); - try { - await tourRepository.reactivateDelivery(event.deliveryId); - opBloc.add(FinishOperation()); - } catch (e, st) { - debugPrint("$e $st"); - _handleError(e, "Fehler beim Zurückstellen der Lieferung"); - } - } - } - - void _holdDelivery(HoldDeliveryEvent event, Emitter emit) async { - final currentState = state; - if (currentState is TourLoaded) { - opBloc.add(StartOperation()); - try { - await tourRepository.holdDelivery(event.deliveryId); - opBloc.add(FinishOperation()); - } catch (e, st) { - debugPrint("$e $st"); - _handleError(e, "Fehler beim Zurückstellen der Lieferung"); - } - } - } - - void _cancelDelivery( - CancelDeliveryEvent event, - Emitter emit, - ) async { - final currentState = state; - if (currentState is TourLoaded) { - opBloc.add(StartOperation()); - try { - await tourRepository.cancelDelivery(event.deliveryId); - opBloc.add(FinishOperation()); - } catch (e, st) { - debugPrint("$e $st"); - _handleError(e, "Fehler beim Stornieren der Lieferung"); - } - } - } - - void _bumpPendingScans(Emitter emit, int delta) { - final currentState = state; - if (currentState is TourLoaded) { - final next = (currentState.pendingScanRequests + delta).clamp(0, 1 << 30); - emit(currentState.copyWith(pendingScanRequests: next)); - } - } - - void _scanComponent( - ScanComponentEvent event, - Emitter emit, - ) async { - final currentState = state; - - if (currentState is TourLoaded) { - _bumpPendingScans(emit, 1); - try { - switch (await tourRepository.scanComponent( - event.deliveryId, - event.carId, - event.componentArticleNumber, - )) { - case ScanResult.scanned: - opBloc.add(FinishOperation(message: 'Komponente gescannt')); - break; - case ScanResult.alreadyScanned: - opBloc.add( - FailOperation(message: 'Komponente wurde bereits gescannt'), - ); - break; - case ScanResult.notFound: - opBloc.add( - FailOperation( - message: 'Komponente ist für keine Lieferung vorgesehen', - ), - ); - break; - } - } catch (e, st) { - debugPrint("FEHLER beim Scannen einer Komponente: $e $st"); - _handleError(e, "Fehler beim Scannen der Komponente"); - } finally { - _bumpPendingScans(emit, -1); - } - } - } - - void _scan(ScanArticleEvent event, Emitter emit) async { - final currentState = state; - - if (currentState is TourLoaded) { - _bumpPendingScans(emit, 1); - try { - switch (await tourRepository.scanArticle( - event.deliveryId, - event.carId, - event.articleNumber, - )) { - case ScanResult.scanned: - opBloc.add(FinishOperation(message: 'Artikel gescannt')); - break; - case ScanResult.alreadyScanned: - opBloc.add( - FailOperation(message: 'Artikel wurde bereits gescannt'), - ); - break; - case ScanResult.notFound: - opBloc.add( - FailOperation( - message: 'Artikel ist für keine Lieferung vorgesehen', - ), - ); - break; - } - } catch (e, st) { - debugPrint("FEHLER beim Scannen eines Artikels: $e $st"); - _handleError(e, "Fehler beim Scannen des Artikels"); - } finally { - _bumpPendingScans(emit, -1); - } - } - } - - Future _increment( - IncrementArticleScanAmount event, - Emitter emit, - ) async { - final currentState = state; - - if (currentState is TourLoaded) { - _bumpPendingScans(emit, 1); - try { - await tourRepository.scanArticle( - event.deliveryId, - event.carId, - event.internalArticleId, - ); - } catch (e, st) { - debugPrint("$e $st"); - _handleError(e, "Fehler beim Scannen des Artikels"); - } finally { - _bumpPendingScans(emit, -1); - } - } - } - - Future _assignCar(AssignCarEvent event, Emitter emit) async { - final currentState = state; - if (currentState is TourLoaded) { - opBloc.add(StartOperation()); - try { - await tourRepository.assignCar(event.deliveryId, event.carId); - opBloc.add(FinishOperation()); - } catch (e, st) { - debugPrint("$e $st"); - _handleError(e, "Fehler beim Zuweisen des Fahrzeugs"); - } - } - } - - Future _unassignDelivery( - UnassignDeliveryEvent event, - Emitter emit, - ) async { - final currentState = state; - if (currentState is! TourLoaded) return; - - opBloc.add(StartOperation()); - try { - // Stub-Aufruf — solange kein echter Endpoint existiert, gibt es - // hier kein Tour-Update; das lokale Modell behält den alten carId- - // Wert. Mit dem echten Endpoint wird die Tour anschließend über - // den tourRepository-Stream aktualisiert (analog zu assignCar). - await processRepository.unassignDeliveryFromCar( - deliveryId: event.deliveryId, - ); - opBloc.add(FinishOperation()); - } catch (e, st) { - debugPrint("$e $st"); - _handleError(e, "Fehler beim Freigeben der Lieferung"); - } - } - - Future _load(LoadTour event, Emitter emit) async { - try { - emit(TourLoading()); - await tourRepository.loadTourOfToday(event.teamId); - await tourRepository.loadPaymentOptions(); - } catch (e) { - if (e is UserUnauthorized) { - authBloc.add(SessionExpiredEvent()); + final details = await tourRepository.getMyTourDetailsOfToday(); + if (details == null) { + emit(const TourEmpty()); return; } - emit(TourLoadingFailed()); - opBloc.add(FailOperation(message: "Fehler beim Laden der heutigen Fahrten")); - } - } - - void _finishDelivery( - FinishDeliveryEvent event, - Emitter emit, - ) async { - final currentState = state; - - if (currentState is TourLoaded) { - opBloc.add(StartOperation(message: "Lieferung wird abgeschlossen…")); - try { - await tourRepository.uploadDriverSignature( - event.deliveryId, - event.driverSignature, - ); - await tourRepository.uploadCustomerSignature( - event.deliveryId, - event.customerSignature, - ); - - await tourRepository.finishDelivery(event.deliveryId); - opBloc.add(FinishOperation(message: "Lieferung abgeschlossen")); - } catch (e, st) { - debugPrint("$e $st"); - _handleError(e, "Fehler beim Abschließen der Lieferung"); + emit(TourLoaded(details: details)); + _pruneAttachmentCache(details); + } catch (e, st) { + debugPrint('TourBloc.RefreshTour fehlgeschlagen: $e\n$st'); + final message = _messageOf(e, 'Tour konnte nicht neu geladen werden'); + // alten Stand sichtbar lassen, Fehler oben mitführen + final latest = state; + if (latest is TourLoaded) { + emit(latest.copyWith(isRefreshing: false, refreshError: message)); + } else { + emit(TourLoadFailed(message: message)); } } } - void _updatePayment( - UpdateSelectedPaymentMethodEvent event, + // ─── ReorderDeliveries ─────────────────────────────────────────────── + + Future _onReorder( + ReorderDeliveries event, Emitter emit, ) async { - opBloc.add(StartOperation()); + final current = state; + if (current is! TourLoaded) return; + + emit(current.copyWith(isPersistingReorder: true, reorderError: null)); try { - await tourRepository.updatePayment(event.deliveryId, event.payment); - opBloc.add(FinishOperation()); + final updated = await tourRepository.setDeliveryOrder( + tourId: current.details.tour.id, + orderedDeliveryIds: event.orderedDeliveryIds, + ); + + final patched = _applySortOrder(current.details, updated); + emit(TourLoaded( + details: patched, + isPersistingReorder: false, + )); } catch (e, st) { - debugPrint("$e $st"); - _handleError(e, "Fehler beim Aktualisieren des Betrags"); + debugPrint('TourBloc.ReorderDeliveries fehlgeschlagen: $e\n$st'); + final message = _messageOf( + e, + 'Reihenfolge konnte nicht gespeichert werden', + ); + final latest = state; + if (latest is TourLoaded) { + emit(latest.copyWith( + isPersistingReorder: false, + reorderError: message, + )); + } + opBloc.add(FailOperation(message: message)); } } - void _updateDeliveryOptions( - UpdateDeliveryOptionEvent event, + TourDetails _applySortOrder( + TourDetails details, + Map newOrderByDeliveryId, + ) { + final next = []; + for (final d in details.deliveries) { + final newOrder = newOrderByDeliveryId[d.id]; + next.add(newOrder == null ? d : d.copyWith(sortOrder: newOrder)); + } + return details.copyWith(deliveries: next); + } + + // ─── AssignCarToDelivery ───────────────────────────────────────────── + + Future _onAssignCar( + AssignCarToDelivery event, Emitter emit, ) async { + final current = state; + if (current is! TourLoaded) return; + opBloc.add(StartOperation()); try { - await tourRepository.updateOption( - event.deliveryId, - event.key, - event.value, + final remoteDelivery = await tourRepository.assignCarToDelivery( + deliveryId: event.deliveryId, + carId: event.carId, ); + + // Server schickt die Delivery ohne Items zurück (Phase-1-Endpoint). + // Wir bauen die lokale Delivery so um, dass alle Stamm-Felder vom + // Server übernommen werden, Items und sortOrder aus dem lokalen + // Aggregat erhalten bleiben. + final localDelivery = current.details.deliveries + .firstWhere((d) => d.id == event.deliveryId, orElse: () => remoteDelivery); + + final merged = remoteDelivery.copyWith( + sortOrder: localDelivery.sortOrder, + items: localDelivery.items, + ); + + emit(current.copyWith(details: current.details.replaceDelivery(merged))); opBloc.add(FinishOperation()); } catch (e, st) { - debugPrint("$e $st"); - _handleError(e, "Fehler beim Aktualisieren der Optionen"); + debugPrint('TourBloc.AssignCarToDelivery fehlgeschlagen: $e\n$st'); + opBloc.add(FailOperation( + message: _messageOf(e, 'Fahrzeug-Zuweisung fehlgeschlagen'), + )); } } - void _updateDiscount( - UpdateDiscountEvent event, + // ─── AssignCarToDeliveries (Bulk) ──────────────────────────────────── + + /// Sequenzielle Variante von [_onAssignCar]: arbeitet die Liste der + /// Lieferungs-Ids in der gegebenen Reihenfolge ab, merged jedes + /// Server-Update mit dem Items-/sortOrder-Stand aus dem lokalen + /// Aggregat und emittiert genau **einen** State am Ende. Damit ist die + /// Aktion atomar — entweder sieht das UI alle erfolgreichen Zuweisungen + /// auf einmal, oder bei einem Fehler die bis dahin erfolgreichen + /// (Backend hat sie bereits committed) zusammen mit einer Fail-Meldung. + Future _onAssignCarBulk( + AssignCarToDeliveries event, Emitter emit, ) async { + final current = state; + if (current is! TourLoaded) return; + if (event.deliveryIds.isEmpty) return; + opBloc.add(StartOperation()); - try { - await tourRepository.updateDiscount( - event.deliveryId, - event.reason, - event.value, - ); + + var workingDetails = current.details; + var successCount = 0; + String? failureMessage; + + for (final deliveryId in event.deliveryIds) { + try { + final remote = await tourRepository.assignCarToDelivery( + deliveryId: deliveryId, + carId: event.carId, + ); + final local = workingDetails.deliveries.firstWhere( + (d) => d.id == deliveryId, + orElse: () => remote, + ); + final merged = remote.copyWith( + sortOrder: local.sortOrder, + items: local.items, + ); + workingDetails = workingDetails.replaceDelivery(merged); + successCount++; + } catch (e, st) { + debugPrint( + 'TourBloc.AssignCarToDeliveries[$deliveryId] fehlgeschlagen: $e\n$st', + ); + failureMessage = _messageOf(e, 'Fahrzeug-Zuweisung fehlgeschlagen'); + break; + } + } + + emit(current.copyWith(details: workingDetails)); + + if (failureMessage != null) { + opBloc.add(FailOperation( + message: successCount == 0 + ? failureMessage + : '$successCount von ${event.deliveryIds.length} Lieferungen ' + 'zugewiesen — $failureMessage', + )); + } else { opBloc.add(FinishOperation()); - } catch (e, st) { - debugPrint("Fehler beim Aktualisieren des Discounts: $e $st"); - _handleError(e, "Fehler beim Aktualisieren des Discounts"); } } - void _removeDiscount( - RemoveDiscountEvent event, + // ─── ScanItem / UnscanItem ─────────────────────────────────────────── + + /// Plus-Eins-Scan: lokal sofort hochzählen, anschließend Backend + /// bestätigen lassen. Bei `rejected` zurückrollen, bei `duplicate` + /// still bleiben (Server-/Client-State stimmen schon überein). + Future _onScanItem(ScanItem event, Emitter emit) async { + final current = state; + if (current is! TourLoaded) return; + + final beforeItem = _findItem(current.details, event.deliveryItemId); + if (beforeItem == null) { + opBloc.add(FailOperation( + message: 'Artikel nicht in der aktuellen Tour gefunden', + )); + return; + } + if (beforeItem.isDone) { + // Defensive: UI sollte „done" gar nicht zum Scan zulassen. Falls doch, + // hier still skippen statt einen rejected vom Server zu provozieren. + opBloc.add(FailOperation(message: 'Artikel ist bereits vollständig')); + return; + } + + // Manuelle Bestätigung verbucht die GANZE Restmenge auf einmal; der + // reguläre (Barcode-)Scan zählt um +1 hoch. + final remaining = + beforeItem.requiredQuantity - beforeItem.scanProgress.scannedQuantity; + final delta = event.manual ? remaining : 1; + final newQuantity = beforeItem.scanProgress.scannedQuantity + delta; + + // ── Optimistisches Update ── + final optimisticItem = beforeItem.copyWith( + scanProgress: beforeItem.scanProgress.copyWith( + scannedQuantity: newQuantity, + lastUpdatedAt: DateTime.now(), + status: newQuantity >= beforeItem.requiredQuantity + ? ScanStatus.done + : ScanStatus.inProgress, + ), + ); + emit(current.copyWith( + details: _replaceItem(current.details, optimisticItem), + )); + + // ── Server-Apply ── + // Bei manueller Bestätigung schickt die App die Restmenge als `quantity` + // mit (Backend verbucht sie als ein Scan-Event, `manual=true`). + final intent = ScanIntent( + clientScanId: _uuid.v4(), + clientScannedAt: DateTime.now(), + deliveryItemId: event.deliveryItemId, + action: ScanAction.scan, + actorCarId: event.actorCarId, + quantity: event.manual ? remaining : null, + manual: event.manual, + ); + await _applyAndReconcile(emit, intent, beforeItem); + } + + Future _onUnscanItem( + UnscanItem event, Emitter emit, ) async { - opBloc.add(StartOperation()); - try { - await tourRepository.removeDiscount(event.deliveryId); - opBloc.add(FinishOperation()); - } catch (e, st) { - debugPrint("Fehler beim Löschen des Discounts: $e $st"); - _handleError(e, "Fehler beim Löschen des Discounts"); + final current = state; + if (current is! TourLoaded) return; + + final beforeItem = _findItem(current.details, event.deliveryItemId); + if (beforeItem == null) return; + if (beforeItem.scanProgress.scannedQuantity == 0) { + opBloc.add(FailOperation( + message: 'Artikel hat keine Scans zum Zurücknehmen', + )); + return; + } + + final optimisticItem = beforeItem.copyWith( + scanProgress: beforeItem.scanProgress.copyWith( + scannedQuantity: beforeItem.scanProgress.scannedQuantity - 1, + lastUpdatedAt: DateTime.now(), + status: ScanStatus.inProgress, + ), + ); + emit(current.copyWith( + details: _replaceItem(current.details, optimisticItem), + )); + + final intent = ScanIntent( + clientScanId: _uuid.v4(), + clientScannedAt: DateTime.now(), + deliveryItemId: event.deliveryItemId, + action: ScanAction.unscan, + actorCarId: event.actorCarId, + reason: event.reason, + ); + await _applyAndReconcile(emit, intent, beforeItem); + } + + Future _onHoldItem(HoldItem event, Emitter emit) async { + final current = state; + if (current is! TourLoaded) return; + + final beforeItem = _findItem(current.details, event.deliveryItemId); + if (beforeItem == null) return; + + final optimisticItem = beforeItem.copyWith( + scanProgress: beforeItem.scanProgress.copyWith( + status: ScanStatus.held, + heldReason: event.reason, + lastUpdatedAt: DateTime.now(), + ), + ); + emit(current.copyWith( + details: _replaceItem(current.details, optimisticItem), + )); + + final intent = ScanIntent( + clientScanId: _uuid.v4(), + clientScannedAt: DateTime.now(), + deliveryItemId: event.deliveryItemId, + action: ScanAction.hold, + actorCarId: event.actorCarId, + reason: event.reason, + ); + await _applyAndReconcile(emit, intent, beforeItem); + } + + Future _onUnholdItem( + UnholdItem event, + Emitter emit, + ) async { + final current = state; + if (current is! TourLoaded) return; + + final beforeItem = _findItem(current.details, event.deliveryItemId); + if (beforeItem == null) return; + + // Beim Unhold lokal zurück auf in_progress; `done` wird beim nächsten + // Server-Refresh ggf. neu gesetzt, falls die scannedQuantity schon + // requiredQuantity erreicht hatte. + final optimisticItem = beforeItem.copyWith( + scanProgress: ScanProgress( + status: beforeItem.scanProgress.scannedQuantity >= + beforeItem.requiredQuantity + ? ScanStatus.done + : ScanStatus.inProgress, + scannedQuantity: beforeItem.scanProgress.scannedQuantity, + lastUpdatedAt: DateTime.now(), + // heldReason explizit weglassen — Backend löscht ihn auch. + ), + ); + emit(current.copyWith( + details: _replaceItem(current.details, optimisticItem), + )); + + final intent = ScanIntent( + clientScanId: _uuid.v4(), + clientScannedAt: DateTime.now(), + deliveryItemId: event.deliveryItemId, + action: ScanAction.unhold, + actorCarId: event.actorCarId, + ); + await _applyAndReconcile(emit, intent, beforeItem); + } + + Future _onRemoveItem( + RemoveItem event, + Emitter emit, + ) async { + final current = state; + if (current is! TourLoaded) return; + + final beforeItem = _findItem(current.details, event.deliveryItemId); + if (beforeItem == null) return; + + // Optimistische Mengen-Gutschrift: `quantity == null` heißt „ganze + // Restmenge". Status kippt erst auf `removed`, wenn voll gutgeschrieben. + final before = beforeItem.scanProgress; + final remaining = beforeItem.requiredQuantity - before.creditedQuantity; + final n = event.quantity ?? remaining; + final newCredited = + (before.creditedQuantity + n).clamp(0, beforeItem.requiredQuantity); + final fully = newCredited >= beforeItem.requiredQuantity; + + final optimisticItem = beforeItem.copyWith( + scanProgress: before.copyWith( + creditedQuantity: newCredited, + status: fully ? ScanStatus.removed : before.status, + lastUpdatedAt: DateTime.now(), + ), + ); + emit(current.copyWith( + details: _replaceItem(current.details, optimisticItem), + )); + + final intent = ScanIntent( + clientScanId: _uuid.v4(), + clientScannedAt: DateTime.now(), + deliveryItemId: event.deliveryItemId, + action: ScanAction.remove, + actorCarId: event.actorCarId, + reason: event.reason, + quantity: event.quantity, + ); + final applied = await _applyAndReconcile(emit, intent, beforeItem); + + // Oberartikel voll entfernt → seine Komponenten lokal ebenfalls als + // entfernt zeigen. Das Backend hat sie in derselben Transaktion bereits + // cascade-entfernt; hier ziehen wir nur die UI nach (der Scan-Response + // liefert nur den State der einen Position). Erst nach bestätigtem + // `applied`, damit ein abgelehnter Versuch die Komponenten nicht fälschlich + // ausblendet. + if (applied && fully) { + final s = state; + if (s is TourLoaded) { + final parentNr = + s.details.articleOf(beforeItem.articleId)?.articleNumber; + Delivery? delivery; + for (final d in s.details.deliveries) { + if (d.id == beforeItem.deliveryId) { + delivery = d; + break; + } + } + if (parentNr != null && delivery != null) { + var details = s.details; + for (final it in delivery.items) { + if (it.parentArtikelNr == parentNr && + it.belegzeilenNr == beforeItem.belegzeilenNr && + it.scanProgress.status != ScanStatus.removed) { + details = _replaceItem( + details, + it.copyWith( + scanProgress: it.scanProgress.copyWith( + creditedQuantity: it.requiredQuantity, + status: ScanStatus.removed, + lastUpdatedAt: DateTime.now(), + ), + ), + ); + } + } + emit(s.copyWith(details: details)); + } + } + } + + // Grund der Gutschrift zusätzlich als Notiz festhalten — aber nur, wenn + // das Entfernen wirklich durchging (kein Note-Eintrag für abgelehnte + // Versuche) und der Aufrufer das angefordert hat (Gutschrift-Flow). + if (applied && event.saveReasonAsNote) { + final articleName = + current.details.articleOf(beforeItem.articleId)?.name ?? + 'Artikel'; + add(AddDeliveryNote( + deliveryId: beforeItem.deliveryId, + text: 'Gutschrift – $articleName ($n×): ${event.reason}', + // Verknüpfung zur Belegzeile → beim Unremove gezielt löschbar. + creditDeliveryItemId: beforeItem.id, + )); } } - void _addDiscount(AddDiscountEvent event, Emitter emit) async { - opBloc.add(StartOperation()); - try { - await tourRepository.addDiscount( + Future _onUnremoveItem( + UnremoveItem event, + Emitter emit, + ) async { + final current = state; + if (current is! TourLoaded) return; + + final beforeItem = _findItem(current.details, event.deliveryItemId); + if (beforeItem == null) return; + + // Optimistische Rücknahme der Gutschrift. `quantity == null` = alles + // zurück. Stand die Zeile auf `removed` (voll gutgeschrieben), kommt sie + // nach Scan-Menge zurück auf `done`/`in_progress`; bei Teil-Rücknahme + // bleibt der Status. `heldReason` wird gelöscht (analog Backend). + final before = beforeItem.scanProgress; + final n = event.quantity ?? before.creditedQuantity; + final newCredited = + (before.creditedQuantity - n).clamp(0, beforeItem.requiredQuantity); + final newStatus = before.status == ScanStatus.removed + ? (before.scannedQuantity >= beforeItem.requiredQuantity + ? ScanStatus.done + : ScanStatus.inProgress) + : before.status; + + final optimisticItem = beforeItem.copyWith( + scanProgress: ScanProgress( + status: newStatus, + scannedQuantity: before.scannedQuantity, + creditedQuantity: newCredited, + lastUpdatedAt: DateTime.now(), + ), + ); + emit(current.copyWith( + details: _replaceItem(current.details, optimisticItem), + )); + + final intent = ScanIntent( + clientScanId: _uuid.v4(), + clientScannedAt: DateTime.now(), + deliveryItemId: event.deliveryItemId, + action: ScanAction.unremove, + actorCarId: event.actorCarId, + quantity: event.quantity, + ); + final applied = await _applyAndReconcile(emit, intent, beforeItem); + + // Spiegelbild zur Notiz-Erzeugung beim Entfernen: nach erfolgreicher + // Rücknahme die zugehörigen Gutschrift-Notizen (mit Bezug auf genau + // diese Belegzeile) wieder löschen. Aus der aktuellsten Notizliste + // gelesen, damit auch nach einem Tour-Reload die richtigen IDs erwischt + // werden. + if (applied) { + final latest = state; + if (latest is TourLoaded) { + final linked = latest.details + .notesOf(beforeItem.deliveryId) + .where((note) => note.creditDeliveryItemId == event.deliveryItemId) + .toList(growable: false); + for (final note in linked) { + add(DeleteDeliveryNote( + deliveryId: beforeItem.deliveryId, + noteId: note.id, + )); + } + } + } + } + + // ─── Betrags-Gutschrift ────────────────────────────────────────────── + + /// Tauscht die Gutschrift einer Lieferung im Aggregat aus (`null` = entfernt). + TourDetails _withCredit( + TourDetails details, + String deliveryId, + DeliveryCredit? credit, + ) { + final next = Map.from(details.creditsByDeliveryId); + if (credit == null) { + next.remove(deliveryId); + } else { + next[deliveryId] = credit; + } + return details.copyWith(creditsByDeliveryId: next); + } + + /// Löscht alle Betrags-Gutschrift-Notizen einer Lieferung am Backend + /// (best-effort — die Notiz ist Beiwerk, die Gutschrift selbst ist führend). + Future _deleteAmountCreditNotes( + String deliveryId, + Iterable notes, + ) async { + for (final note in notes.where((n) => n.isAmountCreditNote)) { + try { + await tourRepository.deleteDeliveryNote( + deliveryId: deliveryId, + noteId: note.id, + ); + } catch (e, st) { + debugPrint('TourBloc: Betrags-Gutschrift-Notiz löschen fehlgeschlagen: $e\n$st'); + } + } + } + + /// State-Update: entfernt alle Betrags-Gutschrift-Notizen der Lieferung aus + /// der Notiz-Map und hängt optional [newNote] an. So bleibt pro Lieferung + /// genau eine aktuelle Grund-Notiz. + TourDetails _replaceAmountCreditNotes( + TourDetails details, + String deliveryId, + DeliveryNote? newNote, + ) { + final nextNotes = Map>.from( + details.notesByDeliveryId, + ); + final existing = nextNotes[deliveryId] ?? const []; + final kept = existing.where((n) => !n.isAmountCreditNote).toList(); + if (newNote != null) kept.add(newNote); + nextNotes[deliveryId] = kept; + return details.copyWith(notesByDeliveryId: nextNotes); + } + + Future _onSetDeliveryCredit( + SetDeliveryCredit event, + Emitter emit, + ) async { + final current = state; + if (current is! TourLoaded) return; + + final previous = current.details.creditOf(event.deliveryId); + // Bestehende Grund-Notizen merken (zum Ersetzen nach Erfolg). + final existingNotes = current.details.notesOf(event.deliveryId); + // Optimistisch sofort anzeigen. + emit(current.copyWith( + details: _withCredit( + current.details, event.deliveryId, - event.reason, - event.value, + DeliveryCredit( + deliveryId: event.deliveryId, + amountCents: event.amountCents, + reason: event.reason, + ), + ), + )); + + // Sichtbares „Request läuft"-Feedback (blockierender Spinner) + Erfolgs-/ + // Fehler-SnackBar über den OperationBloc. + opBloc.add(StartOperation(message: 'Gutschrift wird gespeichert …')); + try { + final result = await tourRepository.setDeliveryCredit( + deliveryId: event.deliveryId, + clientEventId: _uuid.v4(), + amountCents: event.amountCents, + reason: event.reason, + actorCarId: event.actorCarId, ); - opBloc.add(FinishOperation()); + + // Grund als Notiz festhalten (wie beim Artikel-Entfernen). Alte + // Betrags-Gutschrift-Notizen ersetzen, damit pro Lieferung genau eine + // aktuelle Grund-Notiz steht. Best-effort — schlägt es fehl, bleibt die + // Gutschrift trotzdem gesetzt. + DeliveryNote? newNote; + try { + await _deleteAmountCreditNotes(event.deliveryId, existingNotes); + newNote = await tourRepository.addDeliveryNote( + deliveryId: event.deliveryId, + text: 'Betrags-Gutschrift – ${event.amountCents ~/ 100} €: ${event.reason}', + isAmountCreditNote: true, + ); + } catch (e, st) { + debugPrint('TourBloc: Betrags-Gutschrift-Notiz anlegen fehlgeschlagen: $e\n$st'); + } + + // Server ist autoritativ — den zurückgelieferten Stand übernehmen. + final latest = state; + if (latest is TourLoaded) { + var details = _withCredit(latest.details, event.deliveryId, result); + details = + _replaceAmountCreditNotes(details, event.deliveryId, newNote); + emit(latest.copyWith(details: details)); + } + opBloc.add(FinishOperation(message: 'Gutschrift gespeichert')); } catch (e, st) { - debugPrint("Fehler beim Hinzufügen des Discounts: $e $st"); - _handleError(e, "Fehler beim Hinzufügen des Discounts"); + debugPrint('TourBloc.SetDeliveryCredit fehlgeschlagen: $e\n$st'); + final latest = state; + if (latest is TourLoaded) { + emit(latest.copyWith( + details: _withCredit(latest.details, event.deliveryId, previous), + )); + } + opBloc.add(FailOperation( + message: _messageOf(e, 'Gutschrift konnte nicht gespeichert werden'), + )); } } - void _unscan(UnscanArticleEvent event, Emitter emit) async { - opBloc.add(StartOperation()); + Future _onRemoveDeliveryCredit( + RemoveDeliveryCredit event, + Emitter emit, + ) async { + final current = state; + if (current is! TourLoaded) return; + + final previous = current.details.creditOf(event.deliveryId); + if (previous == null) return; // nichts zu entfernen + + final existingNotes = current.details.notesOf(event.deliveryId); + + emit(current.copyWith( + details: _withCredit(current.details, event.deliveryId, null), + )); + + opBloc.add(StartOperation(message: 'Gutschrift wird entfernt …')); try { - await tourRepository.unscan( + await tourRepository.removeDeliveryCredit( + deliveryId: event.deliveryId, + clientEventId: _uuid.v4(), + actorCarId: event.actorCarId, + ); + + // Spiegelbild zum Setzen: zugehörige Grund-Notiz(en) wieder löschen. + await _deleteAmountCreditNotes(event.deliveryId, existingNotes); + final latest = state; + if (latest is TourLoaded) { + emit(latest.copyWith( + details: _replaceAmountCreditNotes( + latest.details, + event.deliveryId, + null, + ), + )); + } + opBloc.add(FinishOperation(message: 'Gutschrift entfernt')); + } catch (e, st) { + debugPrint('TourBloc.RemoveDeliveryCredit fehlgeschlagen: $e\n$st'); + final latest = state; + if (latest is TourLoaded) { + emit(latest.copyWith( + details: _withCredit(latest.details, event.deliveryId, previous), + )); + } + opBloc.add(FailOperation( + message: _messageOf(e, 'Gutschrift konnte nicht entfernt werden'), + )); + } + } + + // ─── Services (Phase 4) ────────────────────────────────────────────── + + /// Tauscht den Service-Wert einer Lieferung im Aggregat aus + /// (`null` = entfernt). + TourDetails _withServiceValue( + TourDetails details, + String deliveryId, + String serviceId, + DeliveryServiceValue? value, + ) { + final outer = Map>.from( + details.serviceValuesByDeliveryId, + ); + final inner = Map.from( + outer[deliveryId] ?? const {}, + ); + if (value == null) { + inner.remove(serviceId); + } else { + inner[serviceId] = value; + } + outer[deliveryId] = inner; + return details.copyWith(serviceValuesByDeliveryId: outer); + } + + Future _onSetDeliveryServiceValue( + SetDeliveryServiceValue event, + Emitter emit, + ) async { + final current = state; + if (current is! TourLoaded) return; + + final previous = + current.details.serviceValueOf(event.deliveryId, event.serviceId); + emit(current.copyWith( + details: _withServiceValue( + current.details, event.deliveryId, - event.articleId, - event.newAmount, - event.reason, + event.serviceId, + DeliveryServiceValue( + deliveryId: event.deliveryId, + serviceId: event.serviceId, + boolValue: event.boolValue, + numericValue: event.numericValue, + ), + ), + )); + + opBloc.add(StartOperation(message: 'Service wird gespeichert …')); + try { + final result = await tourRepository.setDeliveryService( + deliveryId: event.deliveryId, + serviceId: event.serviceId, + boolValue: event.boolValue, + numericValue: event.numericValue, + actorCarId: event.actorCarId, ); - opBloc.add(FinishOperation()); + final latest = state; + if (latest is TourLoaded) { + emit(latest.copyWith( + details: _withServiceValue( + latest.details, + event.deliveryId, + event.serviceId, + result, + ), + )); + } + opBloc.add(FinishOperation(message: 'Service gespeichert')); } catch (e, st) { - debugPrint("Fehler beim Unscan des Artikels ${event.articleId}: $e $st"); - _handleError(e, "Fehler beim Unscan des Artikels"); + debugPrint('TourBloc.SetDeliveryServiceValue fehlgeschlagen: $e\n$st'); + final latest = state; + if (latest is TourLoaded) { + emit(latest.copyWith( + details: _withServiceValue( + latest.details, + event.deliveryId, + event.serviceId, + previous, + ), + )); + } + opBloc.add(FailOperation( + message: _messageOf(e, 'Service konnte nicht gespeichert werden'), + )); } } - void _resetAmount(ResetScanAmountEvent event, Emitter emit) async { + Future _onRemoveDeliveryServiceValue( + RemoveDeliveryServiceValue event, + Emitter emit, + ) async { + final current = state; + if (current is! TourLoaded) return; + + final previous = + current.details.serviceValueOf(event.deliveryId, event.serviceId); + if (previous == null) return; + + emit(current.copyWith( + details: _withServiceValue( + current.details, + event.deliveryId, + event.serviceId, + null, + ), + )); + + opBloc.add(StartOperation(message: 'Service wird entfernt …')); + try { + await tourRepository.removeDeliveryService( + deliveryId: event.deliveryId, + serviceId: event.serviceId, + ); + opBloc.add(FinishOperation(message: 'Service entfernt')); + } catch (e, st) { + debugPrint('TourBloc.RemoveDeliveryServiceValue fehlgeschlagen: $e\n$st'); + final latest = state; + if (latest is TourLoaded) { + emit(latest.copyWith( + details: _withServiceValue( + latest.details, + event.deliveryId, + event.serviceId, + previous, + ), + )); + } + opBloc.add(FailOperation( + message: _messageOf(e, 'Service konnte nicht entfernt werden'), + )); + } + } + + /// Liefert `true`, wenn der Scan serverseitig angewendet (oder als + /// `duplicate` akzeptiert) wurde, `false` bei Ablehnung/Fehler. Aufrufer, + /// die an den Erfolg eine Folgeaktion knüpfen (z. B. die Gutschrift-Notiz), + /// werten den Rückgabewert aus; die übrigen ignorieren ihn. + Future _applyAndReconcile( + Emitter emit, + ScanIntent intent, + DeliveryItem before, + ) async { + try { + final outcomes = await tourRepository.applyScans([intent]); + final outcome = outcomes[intent.clientScanId]; + if (outcome == null) { + // Server hat keinen Eintrag für unseren Scan zurückgegeben — + // defensiv als rejected behandeln. + _rollback(emit, before, 'Unklare Server-Antwort'); + return false; + } + switch (outcome.status) { + case ScanOutcomeStatus.applied: + case ScanOutcomeStatus.duplicate: + // Erfolg. Optimistisches Update bleibt stehen. (Bei `applied` + // könnten wir das Server-`newScanState` zurückmischen; das + // sparen wir uns, weil unsere lokale Inkrement-Logik exakt + // dem Backend-Verhalten entspricht.) + return true; + case ScanOutcomeStatus.rejected: + _rollback(emit, before, outcome.reason ?? 'Scan abgelehnt'); + return false; + } + } catch (e, st) { + debugPrint('TourBloc.applyScans fehlgeschlagen: $e\n$st'); + _rollback(emit, before, _messageOf(e, 'Scan fehlgeschlagen')); + return false; + } + } + + void _rollback(Emitter emit, DeliveryItem before, String reason) { + final current = state; + if (current is! TourLoaded) return; + emit(current.copyWith( + details: _replaceItem(current.details, before), + )); + opBloc.add(FailOperation(message: reason)); + } + + // Lookups/Mutationen am Aggregat --------------------------------------- + + DeliveryItem? _findItem(TourDetails details, String itemId) { + for (final d in details.deliveries) { + for (final it in d.items) { + if (it.id == itemId) return it; + } + } + return null; + } + + TourDetails _replaceItem(TourDetails details, DeliveryItem updated) { + final nextDeliveries = []; + for (final d in details.deliveries) { + if (d.id != updated.deliveryId) { + nextDeliveries.add(d); + continue; + } + final nextItems = []; + for (final it in d.items) { + nextItems.add(it.id == updated.id ? updated : it); + } + nextDeliveries.add(d.copyWith(items: nextItems)); + } + return details.copyWith(deliveries: nextDeliveries); + } + + // ─── Delivery-Lifecycle ────────────────────────────────────────────── + + Future _onCancelDelivery( + CancelDelivery event, + Emitter emit, + ) async { + await _runLifecycle( + emit: emit, + deliveryId: event.deliveryId, + operationLabel: 'Lieferung abbrechen', + apply: () => tourRepository.cancelDelivery( + deliveryId: event.deliveryId, + reason: event.reason, + ), + ); + } + + Future _onHoldDelivery( + HoldDelivery event, + Emitter emit, + ) async { + await _runLifecycle( + emit: emit, + deliveryId: event.deliveryId, + operationLabel: 'Lieferung pausieren', + apply: () => tourRepository.holdDelivery( + deliveryId: event.deliveryId, + reason: event.reason, + ), + ); + } + + Future _onResumeDelivery( + ResumeDelivery event, + Emitter emit, + ) async { + await _runLifecycle( + emit: emit, + deliveryId: event.deliveryId, + operationLabel: 'Lieferung fortsetzen', + apply: () => + tourRepository.resumeDelivery(deliveryId: event.deliveryId), + ); + } + + Future _onCompleteDelivery( + CompleteDelivery event, + Emitter emit, + ) async { + await _runLifecycle( + emit: emit, + deliveryId: event.deliveryId, + operationLabel: 'Lieferung abschließen', + apply: () => tourRepository.completeDelivery( + deliveryId: event.deliveryId, + customerSignaturePng: event.customerSignaturePng, + driverSignaturePng: event.driverSignaturePng, + receiptConfirmed: event.receiptConfirmed, + notesAcknowledged: event.notesAcknowledged, + acknowledgedNoteIds: event.acknowledgedNoteIds, + paymentMethodId: event.paymentMethodId, + actorCarId: event.actorCarId, + paymentCollected: event.paymentCollected, + ), + ); + } + + /// Gemeinsamer Pfad für die drei Lifecycle-Operationen: globaler + /// Loading-Overlay an, Server-Call, lokales Aggregat mit der neuen + /// Stamm-Delivery mergen (Items/sortOrder aus dem aktuellen State + /// behalten), Overlay aus. Konservativ — kein optimistisches Update, + /// weil das selten genug passiert und der Fahrer eh bewusst auf den + /// Reason-Dialog reagiert. + Future _runLifecycle({ + required Emitter emit, + required String deliveryId, + required String operationLabel, + required Future Function() apply, + }) async { + final current = state; + if (current is! TourLoaded) return; + opBloc.add(StartOperation()); try { - await tourRepository.resetScan(event.articleId, event.deliveryId); + final remote = await apply(); + final local = current.details.deliveries + .firstWhere((d) => d.id == deliveryId, orElse: () => remote); + final merged = remote.copyWith( + sortOrder: local.sortOrder, + items: local.items, + ); + emit(current.copyWith( + details: current.details.replaceDelivery(merged), + )); opBloc.add(FinishOperation()); } catch (e, st) { - debugPrint("Fehler beim Zurücksetzen Artikel ${event.articleId}: $e $st"); - _handleError(e, "Fehler beim Zurücksetzen"); + debugPrint('TourBloc.$operationLabel fehlgeschlagen: $e\n$st'); + opBloc.add(FailOperation( + message: _messageOf(e, '$operationLabel fehlgeschlagen'), + )); } } + + // ─── AddDeliveryNote ───────────────────────────────────────────────── + + /// Schreibt eine Notiz an einer Lieferung und hängt sie in das lokale + /// Tour-Aggregat ein. Bewusst kein optimistisches Update — die UI zeigt + /// einen kurzen Loader und bekommt erst danach die Notiz mit echter + /// Server-Id zurück; das vermeidet Geister-Notizen, wenn der Call kippt. + Future _onAddDeliveryNote( + AddDeliveryNote event, + Emitter emit, + ) async { + final current = state; + if (current is! TourLoaded) return; + + opBloc.add(StartOperation()); + try { + final note = await tourRepository.addDeliveryNote( + deliveryId: event.deliveryId, + text: event.text, + imageAttachment: event.imageAttachment, + creditDeliveryItemId: event.creditDeliveryItemId, + ); + final nextNotes = Map>.from( + current.details.notesByDeliveryId, + ); + final existing = nextNotes[event.deliveryId] ?? const []; + // Backend liefert die Liste pro Lieferung nach `createdAt` sortiert — + // wir hängen die neue Notiz hinten an, weil sie als jüngste Notiz + // automatisch am Ende landet. + nextNotes[event.deliveryId] = [...existing, note]; + emit(current.copyWith( + details: current.details.copyWith(notesByDeliveryId: nextNotes), + )); + opBloc.add(FinishOperation()); + } catch (e, st) { + debugPrint('TourBloc.AddDeliveryNote fehlgeschlagen: $e\n$st'); + opBloc.add(FailOperation( + message: _messageOf(e, 'Notiz konnte nicht gespeichert werden'), + )); + } + } + + // ─── UpdateDeliveryNote ────────────────────────────────────────────── + + Future _onUpdateDeliveryNote( + UpdateDeliveryNote event, + Emitter emit, + ) async { + final current = state; + if (current is! TourLoaded) return; + + opBloc.add(StartOperation()); + try { + final updated = await tourRepository.updateDeliveryNote( + deliveryId: event.deliveryId, + noteId: event.noteId, + text: event.text, + imageAttachment: event.imageAttachment, + ); + final nextNotes = Map>.from( + current.details.notesByDeliveryId, + ); + final list = List.of( + nextNotes[event.deliveryId] ?? const [], + ); + final idx = list.indexWhere((n) => n.id == event.noteId); + if (idx != -1) { + list[idx] = updated; + } + nextNotes[event.deliveryId] = list; + emit(current.copyWith( + details: current.details.copyWith(notesByDeliveryId: nextNotes), + )); + opBloc.add(FinishOperation()); + } catch (e, st) { + debugPrint('TourBloc.UpdateDeliveryNote fehlgeschlagen: $e\n$st'); + opBloc.add(FailOperation( + message: _messageOf(e, 'Notiz konnte nicht geändert werden'), + )); + } + } + + // ─── DeleteDeliveryNote ────────────────────────────────────────────── + + Future _onDeleteDeliveryNote( + DeleteDeliveryNote event, + Emitter emit, + ) async { + final current = state; + if (current is! TourLoaded) return; + + opBloc.add(StartOperation()); + try { + await tourRepository.deleteDeliveryNote( + deliveryId: event.deliveryId, + noteId: event.noteId, + ); + final nextNotes = Map>.from( + current.details.notesByDeliveryId, + ); + final list = List.of( + nextNotes[event.deliveryId] ?? const [], + )..removeWhere((n) => n.id == event.noteId); + if (list.isEmpty) { + nextNotes.remove(event.deliveryId); + } else { + nextNotes[event.deliveryId] = list; + } + emit(current.copyWith( + details: current.details.copyWith(notesByDeliveryId: nextNotes), + )); + opBloc.add(FinishOperation()); + } catch (e, st) { + debugPrint('TourBloc.DeleteDeliveryNote fehlgeschlagen: $e\n$st'); + opBloc.add(FailOperation( + message: _messageOf(e, 'Notiz konnte nicht gelöscht werden'), + )); + } + } + + // ─── UploadDeliveryNoteImage ───────────────────────────────────────── + + Future _onUploadDeliveryNoteImage( + UploadDeliveryNoteImage event, + Emitter emit, + ) async { + final current = state; + if (current is! TourLoaded) return; + + opBloc.add(StartOperation()); + try { + final note = await tourRepository.uploadDeliveryNoteImage( + deliveryId: event.deliveryId, + filename: event.filename, + mime: event.mime, + bytes: event.bytes, + ); + final nextNotes = Map>.from( + current.details.notesByDeliveryId, + ); + final existing = nextNotes[event.deliveryId] ?? const []; + nextNotes[event.deliveryId] = [...existing, note]; + emit(current.copyWith( + details: current.details.copyWith(notesByDeliveryId: nextNotes), + )); + opBloc.add(FinishOperation()); + } catch (e, st) { + debugPrint('TourBloc.UploadDeliveryNoteImage fehlgeschlagen: $e\n$st'); + opBloc.add(FailOperation( + message: _messageOf(e, 'Bild konnte nicht hochgeladen werden'), + )); + } + } + + // ─── Helpers ───────────────────────────────────────────────────────── + + String _messageOf(Object e, String fallback) { + if (e is TourRepositoryException) return e.message; + return fallback; + } } diff --git a/lib/feature/delivery/bloc/tour_event.dart b/lib/feature/delivery/bloc/tour_event.dart index 1311060..af676ff 100644 --- a/lib/feature/delivery/bloc/tour_event.dart +++ b/lib/feature/delivery/bloc/tour_event.dart @@ -1,277 +1,341 @@ -import 'dart:typed_data'; - -import 'package:hl_lieferservice/model/car.dart'; -import 'package:hl_lieferservice/model/tour.dart'; - -import '../../../../model/delivery.dart'; - -abstract class TourEvent {} +/// Events für den [`TourBloc`]. +/// +/// Bewusst minimaler Scope für Phase C+D-2: Lese-Pfad, Reorder, +/// Car-Assign. Scan-, Hold-, Cancel-, Complete-, Discount- und +/// Notiz-Events kommen in C+D-3 / C+D-4 zurück. +sealed class TourEvent { + const TourEvent(); +} +/// Initial-Load der heutigen Tour des angemeldeten Fahrers. +/// Account-Filter sitzt im JWT — kein zusätzliches Argument nötig. class LoadTour extends TourEvent { - String teamId; - - LoadTour({required this.teamId}); + const LoadTour(); } -class RequestDeliveryDistanceEvent extends TourEvent { - Tour tour; - - RequestDeliveryDistanceEvent({required this.tour}); +/// Pull-to-refresh / manueller Reload aus der Übersicht. Vorhandener +/// `TourLoaded`-State bleibt sichtbar, bis der neue Snapshot da ist; +/// `TourLoading` wird nur emittiert, wenn vorher kein State existierte. +class RefreshTour extends TourEvent { + const RefreshTour(); } -class RequestSortingInformationEvent extends TourEvent { - Tour tour; - List payments; +/// Schreibt die Sortier-Reihenfolge aller Lieferungen einer Tour an das +/// Backend. Der Bloc fordert die UI nicht auf, die vollständige Reihenfolge +/// zu kennen — sie wird aus der Liste übergeben. +class ReorderDeliveries extends TourEvent { + const ReorderDeliveries({required this.orderedDeliveryIds}); - RequestSortingInformationEvent({ - required this.tour, - required this.payments, - }); + final List orderedDeliveryIds; } -class ReorderDeliveryEvent extends TourEvent { - int newPosition; - int oldPosition; - String carId; +/// Weist einer Lieferung ein Fahrzeug zu — oder hebt die Zuweisung auf +/// (`carId == null`). +class AssignCarToDelivery extends TourEvent { + const AssignCarToDelivery({required this.deliveryId, required this.carId}); - ReorderDeliveryEvent({required this.newPosition, required this.oldPosition, required this.carId}); -} - -/// Ersetzt die komplette Sortier-Information (z. B. beim Zurücksetzen auf -/// die Default-Reihenfolge in der Sortier-Page). Persistiert lokal über -/// den ReorderService, kein Backend-Call. -class ReplaceSortingEvent extends TourEvent { - final String carId; - final Map> newSortingInformation; - - ReplaceSortingEvent({ - required this.carId, - required this.newSortingInformation, - }); -} - -/// Bestätigung der Sortierung durch den Fahrer. Löst den (aktuell ge- -/// stubbten) Backend-Call zur Persistierung der Reihenfolge aus und -/// schaltet bei Erfolg in Phase [DeliveryPhase.beladen]. -class ConfirmSortingEvent extends TourEvent { - final String carId; - - ConfirmSortingEvent({required this.carId}); -} - -/// Stellt sicher, dass der Sortier-Bucket für [carId] alle aktuell in der -/// Tour vorhandenen Lieferungen enthält — und zwar UNABHÄNGIG von -/// `delivery.carId`. Hintergrund: zum Sortier-Zeitpunkt sind die Lieferungen -/// dem Fahrzeug oft noch nicht zugeordnet (das passiert erst beim Scannen -/// während des Beladens). Der Fahrer hat aber bereits sein Tagesfahrzeug -/// gewählt, also gehören alle Tour-Lieferungen in diesen Bucket. Bestehende -/// Reihenfolge bleibt erhalten, fehlende IDs werden hinten angehängt. -class EnsureSortingForCarEvent extends TourEvent { - final String carId; - - EnsureSortingForCarEvent({required this.carId}); -} - -class TourUpdated extends TourEvent { - Tour tour; - List payments; - - TourUpdated({required this.tour, required this.payments}); -} - -class PaymentOptionsUpdated extends TourEvent { - List options; - - PaymentOptionsUpdated({required this.options}); -} - -class UpdateTour extends TourEvent { - Tour tour; - - UpdateTour({required this.tour}); -} - -class AssignCarEvent extends TourEvent { - String deliveryId; - String carId; - - AssignCarEvent({required this.deliveryId, required this.carId}); -} - -/// Hebt die Fahrzeug-Zuordnung einer Lieferung wieder auf. Wird im -/// Auswahl-Schritt ausgelöst, wenn der Fahrer eine eigene Lieferung -/// "freigibt", damit ein Kollege sie übernehmen kann. Aktuell ruft der -/// Handler nur den ProcessRepository-Stub auf — ein echtes Update der -/// Tour erfolgt erst mit dem realen Backend-Endpoint (siehe Repository). -class UnassignDeliveryEvent extends TourEvent { final String deliveryId; - - UnassignDeliveryEvent({required this.deliveryId}); + final String? carId; } -class IncrementArticleScanAmount extends TourEvent { - String internalArticleId; - String deliveryId; - String carId; - - IncrementArticleScanAmount({ - required this.internalArticleId, - required this.deliveryId, +/// Bulk-Variante: weist mehreren Lieferungen in einer atomaren Aktion das +/// gleiche Fahrzeug zu. Notwendig, weil flutter_bloc Events standardmäßig +/// **concurrent** verarbeitet — N parallel laufende `AssignCarToDelivery`- +/// Handler lesen alle den Initial-State und überschreiben sich gegenseitig +/// beim `emit`, sodass nur die letzte Zuweisung sichtbar bleibt. Der +/// Bulk-Handler verarbeitet die Liste sequenziell und emittiert genau +/// einen Zwischen-State am Ende. +/// +/// Backend-seitig macht der Handler trotzdem N einzelne HTTP-Calls, weil +/// der `PATCH /deliveries/{id}/assigned-car`-Endpoint keine Liste kennt. +class AssignCarToDeliveries extends TourEvent { + const AssignCarToDeliveries({ + required this.deliveryIds, required this.carId, }); + + final List deliveryIds; + final String? carId; } -class ScanArticleEvent extends TourEvent { - ScanArticleEvent({ - required this.articleNumber, - required this.carId, - required this.deliveryId, +/// Scan-Trigger aus der Loading-Phase. Der Bloc inkrementiert lokal +/// **optimistisch** die Scan-Quantität, ruft anschließend `POST /scans` +/// und rollt im `rejected`-Fall lokal zurück. `duplicate` ist still +/// (lokal schon hochgezählt, Server bestätigt nicht erneut). +/// +/// `actorCarId` ist die UUID des aktuell gewählten Fahrzeugs — das +/// Backend nutzt das nur als Audit-Spur; an der Lieferung selbst +/// passiert dadurch nichts. +class ScanItem extends TourEvent { + const ScanItem({ + required this.deliveryItemId, + required this.actorCarId, + this.manual = false, }); - String articleNumber; - String deliveryId; - String carId; + final String deliveryItemId; + final String actorCarId; + + /// `true` = manuelle Zeilen-Bestätigung (Fallback ohne Barcode): die + /// **gesamte Restmenge** wird auf einmal als geladen verbucht und im + /// Backend als `manual` protokolliert. `false` = regulärer Einzel-Scan (+1). + final bool manual; } -/// Scan a single BOM component. The server call for the parent article is -/// deferred until *all* components are fully scanned. -class ScanComponentEvent extends TourEvent { - ScanComponentEvent({ - required this.componentArticleNumber, - required this.carId, - required this.deliveryId, - }); - - String componentArticleNumber; - String deliveryId; - String carId; -} - -class CancelDeliveryEvent extends TourEvent { - String deliveryId; - - CancelDeliveryEvent({required this.deliveryId}); -} - -class HoldDeliveryEvent extends TourEvent { - String deliveryId; - - HoldDeliveryEvent({required this.deliveryId}); -} - -class ReactivateDeliveryEvent extends TourEvent { - String deliveryId; - - ReactivateDeliveryEvent({required this.deliveryId}); -} - -class LoadDeliveryEvent extends TourEvent { - LoadDeliveryEvent({required this.delivery}); - - Delivery delivery; -} - -class UnscanArticleEvent extends TourEvent { - UnscanArticleEvent({ - required this.articleId, - required this.newAmount, - required this.reason, - required this.deliveryId, - }); - - String articleId; - String deliveryId; - String reason; - int newAmount; -} - -class ResetScanAmountEvent extends TourEvent { - ResetScanAmountEvent({required this.articleId, required this.deliveryId}); - - String articleId; - String deliveryId; -} - -class AddDiscountEvent extends TourEvent { - AddDiscountEvent({ - required this.deliveryId, - required this.value, +/// Umkehrung eines Scans (z. B. „falsch gescannt"). `reason` ist vom +/// Backend für `unscan` erforderlich. +class UnscanItem extends TourEvent { + const UnscanItem({ + required this.deliveryItemId, + required this.actorCarId, required this.reason, }); - String deliveryId; - String reason; - int value; + final String deliveryItemId; + final String actorCarId; + final String reason; } -class RemoveDiscountEvent extends TourEvent { - RemoveDiscountEvent({required this.deliveryId}); - - String deliveryId; -} - -class UpdateDiscountEvent extends TourEvent { - UpdateDiscountEvent({ - required this.deliveryId, - required this.value, +/// Pausiert ein Item (`scan_status=held`). Reversibel über [UnholdItem]. +/// `reason` Pflicht. +class HoldItem extends TourEvent { + const HoldItem({ + required this.deliveryItemId, + required this.actorCarId, required this.reason, }); - String deliveryId; - String? reason; - int? value; + final String deliveryItemId; + final String actorCarId; + final String reason; } -class CarsLoadedEvent extends TourEvent { - List cars; - - CarsLoadedEvent({required this.cars}); -} - -class UpdateDeliveryOptionEvent extends TourEvent { - UpdateDeliveryOptionEvent({ - required this.key, - required this.value, - required this.deliveryId, +/// Setzt ein pausiertes Item zurück auf `in_progress`. Kein Reason +/// nötig — der Server löscht beim Unhold den `held_reason`. +class UnholdItem extends TourEvent { + const UnholdItem({ + required this.deliveryItemId, + required this.actorCarId, }); - String deliveryId; - String key; - dynamic value; + final String deliveryItemId; + final String actorCarId; } -class UpdateSelectedPaymentMethodEvent extends TourEvent { - UpdateSelectedPaymentMethodEvent({ - required this.payment, - required this.deliveryId, +/// Entfernt ein Item aus der Lieferung (`scan_status=removed`). +/// `reason` Pflicht. Reversibel über [UnremoveItem] — die Historie +/// bleibt im Audit-Log vollständig erhalten. +class RemoveItem extends TourEvent { + const RemoveItem({ + required this.deliveryItemId, + required this.actorCarId, + required this.reason, + this.quantity, + this.saveReasonAsNote = false, }); - Payment payment; - String deliveryId; + final String deliveryItemId; + final String actorCarId; + final String reason; + + /// Mengen-Gutschrift: wie viele Stück gutgeschrieben werden. `null` = + /// ganze Restmenge (= klassisches „ganze Zeile entfernen"). + final int? quantity; + + /// Wenn `true`, wird der `reason` nach erfolgreichem Entfernen zusätzlich + /// als Lieferungs-Notiz gespeichert. Gesetzt aus dem Gutschrift-Flow + /// (Step „Artikel & Gutschriften"); im Beladen-Flow `false`, dort wäre + /// eine Notiz pro Abbuchung nur Rauschen. + final bool saveReasonAsNote; } -class FinishDeliveryEvent extends TourEvent { - FinishDeliveryEvent({ - required this.deliveryId, - required this.driverSignature, - required this.customerSignature, +/// Stellt ein entferntes Item wieder her — geht nur, wenn der aktuelle +/// Status `removed` ist. Kein Reason nötig (Backend-Konvention wie bei +/// `unscan` und `unhold`). Audit-Log behält den ursprünglichen +/// `remove`-Eintrag und fügt einen neuen `unremove`-Eintrag hinzu. +class UnremoveItem extends TourEvent { + const UnremoveItem({ + required this.deliveryItemId, + required this.actorCarId, + this.quantity, }); - String deliveryId; - Uint8List customerSignature; - Uint8List driverSignature; + final String deliveryItemId; + final String actorCarId; + + /// Mengen-Gutschrift zurücknehmen: wie viele Stück wiederhergestellt + /// werden. `null` = gesamte Gutschrift zurück. + final int? quantity; } -class SetArticleAmountEvent extends TourEvent { +// ─── Delivery-Lifecycle ─────────────────────────────────────────────── + +/// Bricht eine Lieferung endgültig ab. `reason` ist Pflicht. +class CancelDelivery extends TourEvent { + const CancelDelivery({required this.deliveryId, required this.reason}); + final String deliveryId; - final String articleId; - final String? reason; - final int amount; + final String reason; +} - SetArticleAmountEvent({ +/// Pausiert eine Lieferung. `reason` ist Pflicht. Reversibel über +/// [ResumeDelivery]. +class HoldDelivery extends TourEvent { + const HoldDelivery({required this.deliveryId, required this.reason}); + + final String deliveryId; + final String reason; +} + +/// Setzt eine pausierte Lieferung zurück auf `active`. +class ResumeDelivery extends TourEvent { + const ResumeDelivery({required this.deliveryId}); + + final String deliveryId; +} + +/// Schließt eine Lieferung ab: lädt beide Unterschriften hoch, dokumentiert +/// die Bestätigungen des Kunden und setzt die Lieferung auf `completed`. +class CompleteDelivery extends TourEvent { + const CompleteDelivery({ required this.deliveryId, - required this.articleId, - required this.amount, - this.reason, + required this.customerSignaturePng, + required this.driverSignaturePng, + required this.receiptConfirmed, + required this.notesAcknowledged, + required this.acknowledgedNoteIds, + this.paymentMethodId, + this.actorCarId, + this.paymentCollected = false, }); -} \ No newline at end of file + + final String deliveryId; + final List customerSignaturePng; + final List driverSignaturePng; + final bool receiptConfirmed; + final bool notesAcknowledged; + final List acknowledgedNoteIds; + + /// Vom Fahrer im Summary gewählte Zahlungsmethode (Override). `null` = + /// die am Beleg hinterlegte Methode bleibt. Wird beim Abschluss persistiert. + final String? paymentMethodId; + final String? actorCarId; + + /// Fahrer hat das Vor-Ort-Inkasso (Bar/EC) des offenen Betrags bestätigt. + /// `false`, wenn kein Inkasso anfiel (offen == 0 oder „Auf Rechnung"). + final bool paymentCollected; +} + +/// Legt eine neue (Text- oder Bild-)Notiz an einer Lieferung an. Aktuell +/// wird nur der Text-Pfad von der UI getriggert; `imageAttachment` ist als +/// Storage-Key (z. B. Pre-Signed-URL-Key) gedacht und wartet auf die +/// zukünftige Foto-Upload-Phase. +/// Setzt/ändert die Betrags-Gutschrift einer Lieferung (Geld-Nachlass). +class SetDeliveryCredit extends TourEvent { + const SetDeliveryCredit({ + required this.deliveryId, + required this.amountCents, + required this.reason, + required this.actorCarId, + }); + + final String deliveryId; + final int amountCents; + final String reason; + final String actorCarId; +} + +/// Entfernt die Betrags-Gutschrift einer Lieferung. +class RemoveDeliveryCredit extends TourEvent { + const RemoveDeliveryCredit({ + required this.deliveryId, + required this.actorCarId, + }); + + final String deliveryId; + final String actorCarId; +} + +/// Setzt/ändert den Wert eines Service für eine Lieferung (Checkbox/Zahl). +class SetDeliveryServiceValue extends TourEvent { + const SetDeliveryServiceValue({ + required this.deliveryId, + required this.serviceId, + required this.actorCarId, + this.boolValue, + this.numericValue, + }); + + final String deliveryId; + final String serviceId; + final String actorCarId; + final bool? boolValue; + final int? numericValue; +} + +/// Entfernt den Service-Wert einer Lieferung (Service „nicht gesetzt"). +class RemoveDeliveryServiceValue extends TourEvent { + const RemoveDeliveryServiceValue({ + required this.deliveryId, + required this.serviceId, + }); + + final String deliveryId; + final String serviceId; +} + +class AddDeliveryNote extends TourEvent { + const AddDeliveryNote({ + required this.deliveryId, + this.text, + this.imageAttachment, + this.creditDeliveryItemId, + }); + + final String deliveryId; + final String? text; + final String? imageAttachment; + + /// Optionaler Gutschrift-Bezug (DeliveryItem-Id). Gesetzt, wenn die Notiz + /// einen Gutschrift-Grund dokumentiert — ermöglicht das Löschen beim + /// Unremove. + final String? creditDeliveryItemId; +} + +/// Ändert Text/Bild einer bestehenden Notiz. +class UpdateDeliveryNote extends TourEvent { + const UpdateDeliveryNote({ + required this.deliveryId, + required this.noteId, + this.text, + this.imageAttachment, + }); + + final String deliveryId; + final String noteId; + final String? text; + final String? imageAttachment; +} + +/// Löscht eine Notiz. +class DeleteDeliveryNote extends TourEvent { + const DeleteDeliveryNote({required this.deliveryId, required this.noteId}); + + final String deliveryId; + final String noteId; +} + +/// Lädt ein Bild als Notiz hoch (geht über DOCUframe). +class UploadDeliveryNoteImage extends TourEvent { + const UploadDeliveryNoteImage({ + required this.deliveryId, + required this.filename, + required this.mime, + required this.bytes, + }); + + final String deliveryId; + final String filename; + final String mime; + final List bytes; +} diff --git a/lib/feature/delivery/bloc/tour_state.dart b/lib/feature/delivery/bloc/tour_state.dart index 2784af2..3be006d 100644 --- a/lib/feature/delivery/bloc/tour_state.dart +++ b/lib/feature/delivery/bloc/tour_state.dart @@ -1,63 +1,95 @@ -import '../../../../model/tour.dart'; +import 'package:hl_lieferservice/domain/entity/tour_details.dart'; -abstract class TourState {} +/// Lifecycle-States des `TourBloc`. +/// +/// Bewusst eine sealed-Hierarchie: das UI kann via `switch` exhaustiv +/// alle Pfade abbilden und der Compiler meldet, wenn ein neuer Pfad +/// dazukommt. +sealed class TourState { + const TourState(); +} -class TourInitial extends TourState {} +/// App-Start, bevor irgendetwas geladen wurde. +class TourInitial extends TourState { + const TourInitial(); +} -class TourLoading extends TourState {} +/// Initial-Load läuft. Wird nur emittiert, wenn vorher kein Tour-State da +/// war — für Refresh siehe `TourLoaded.isRefreshing`. +class TourLoading extends TourState { + const TourLoading(); +} -class TourLoadingFailed extends TourState {} +/// Initial-Load ist gescheitert. Refresh-Fehler hingegen werden in +/// `TourLoaded.refreshError` getragen, damit die alte Tour sichtbar bleibt. +class TourLoadFailed extends TourState { + const TourLoadFailed({required this.message}); + final String message; +} + +/// Erfolgreich geladen — beinhaltet das volle Tour-Aggregat sowie +/// UI-relevante Zusatzflags rund um Reorder- und Refresh-Operationen. class TourLoaded extends TourState { - Tour tour; - Map? distances; - List paymentOptions; - Map> sortingInformation; - - /// Number of scan-related server requests currently in flight. Drives the - /// inline indicator on the scanner widget. Using a counter (not bool) lets - /// rapid-fire scans coexist without one prematurely clearing the indicator. - int pendingScanRequests; - - /// True während der Backend-Call zur Persistierung der Sortier-Reihenfolge - /// läuft. Wird vom Bestätigungs-Button in der Sortier-Page für Spinner - /// und Button-Disabled-State ausgewertet. - bool isPersistingSorting; - - /// Letzte Fehlermeldung des Sortier-Persist-Calls. Wird nach Anzeige - /// durch das UI ge-leert (z. B. nach SnackBar). - String? sortingPersistError; - - TourLoaded({ - required this.tour, - this.distances, - required this.paymentOptions, - required this.sortingInformation, - this.pendingScanRequests = 0, - this.isPersistingSorting = false, - this.sortingPersistError, + const TourLoaded({ + required this.details, + this.isRefreshing = false, + this.isPersistingReorder = false, + this.refreshError, + this.reorderError, }); + final TourDetails details; + + /// Hintergrund-Reload läuft (Pull-to-Refresh, Provider-Wakeup). UI darf + /// die alte Daten weiter zeigen und nur einen schmalen Indikator + /// einblenden. + final bool isRefreshing; + + /// `PUT /tours/{id}/delivery-order` läuft. Sortier-Page nutzt das für + /// den Bestätigungs-Button. + final bool isPersistingReorder; + + /// Fehler eines Hintergrund-Reloads — bleibt für eine einzelne Snackbar + /// hängen und wird beim nächsten Reload geleert. + final String? refreshError; + + /// Fehler des letzten Reorder-Persist-Versuchs. + final String? reorderError; + TourLoaded copyWith({ - Tour? tour, - Map? distances, - List? paymentOptions, - Map>? sortingInformation, - int? pendingScanRequests, - bool? isPersistingSorting, - String? sortingPersistError, - bool clearSortingPersistError = false, + TourDetails? details, + bool? isRefreshing, + bool? isPersistingReorder, + Object? refreshError = _sentinel, + Object? reorderError = _sentinel, }) { return TourLoaded( - tour: tour ?? this.tour, - distances: distances ?? this.distances, - paymentOptions: paymentOptions ?? this.paymentOptions, - sortingInformation: sortingInformation ?? this.sortingInformation, - pendingScanRequests: pendingScanRequests ?? this.pendingScanRequests, - isPersistingSorting: isPersistingSorting ?? this.isPersistingSorting, - sortingPersistError: clearSortingPersistError - ? null - : (sortingPersistError ?? this.sortingPersistError), + details: details ?? this.details, + isRefreshing: isRefreshing ?? this.isRefreshing, + isPersistingReorder: isPersistingReorder ?? this.isPersistingReorder, + refreshError: identical(refreshError, _sentinel) + ? this.refreshError + : refreshError as String?, + reorderError: identical(reorderError, _sentinel) + ? this.reorderError + : reorderError as String?, ); } + + /// Spezialfall: Initial-Load ist erfolgreich, aber das Backend hat dem + /// angemeldeten Fahrer keine Tour für heute zugewiesen (kein ERP-Sync, + /// Urlaub, …). UI kann darauf einen freundlichen Hinweis statt einer + /// leeren Liste anzeigen. + bool get isEmpty => details.deliveries.isEmpty; +} + +const Object _sentinel = Object(); + +/// Erfolgs-Spezialform für „heute keine Tour zugewiesen". Wir behandeln das +/// als eigenständigen State (statt als `TourLoaded` mit leeren Listen), +/// damit das UI im Routing klar trennen kann zwischen „Tour vorhanden, +/// gerade keine Lieferungen offen" und „gar keine Tour für heute". +class TourEmpty extends TourState { + const TourEmpty(); } diff --git a/lib/feature/delivery/detail/bloc/note_bloc.dart b/lib/feature/delivery/detail/bloc/note_bloc.dart deleted file mode 100644 index d2c88bc..0000000 --- a/lib/feature/delivery/detail/bloc/note_bloc.dart +++ /dev/null @@ -1,172 +0,0 @@ -import 'dart:async'; -import 'dart:typed_data'; - -import 'package:flutter/cupertino.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:hl_lieferservice/feature/authentication/bloc/auth_bloc.dart'; -import 'package:hl_lieferservice/feature/authentication/bloc/auth_event.dart'; -import 'package:hl_lieferservice/feature/authentication/exceptions.dart'; -import 'package:hl_lieferservice/widget/operations/bloc/operation_bloc.dart'; -import 'package:hl_lieferservice/widget/operations/bloc/operation_event.dart'; -import 'package:rxdart/rxdart.dart'; - -import '../../../../model/delivery.dart'; -import 'note_event.dart'; -import 'note_state.dart'; -import 'package:hl_lieferservice/feature/delivery/detail/repository/note_repository.dart'; - -class NoteBloc extends Bloc { - final NoteRepository repository; - final OperationBloc opBloc; - final AuthBloc authBloc; - final String deliveryId; - - StreamSubscription? _combinedSubscription; - - NoteBloc({ - required this.repository, - required this.opBloc, - required this.authBloc, - required this.deliveryId, - }) : super(NoteInitial()) { - _combinedSubscription = CombineLatestStream.combine3( - repository.notes, - repository.images, - repository.templates, - (note, image, templates) { - if (note == null || image == null || templates == null) { - return null; - } - - return {"note": note, "image": image, "templates": templates}; - }, - ) - .where((data) => data != null) - .listen( - (data) => add( - DataUpdated( - images: data!["image"] as List, - notes: data["note"] as List, - templates: data["templates"] as List, - ), - ), - ); - - on(_load); - on(_add); - on(_edit); - on(_remove); - on(_upload); - on(_removeImage); - on(_reset); - on(_dataUpdated); - } - - @override - Future close() { - _combinedSubscription?.cancel(); - return super.close(); - } - - void _handleError(Object e, String fallbackMessage) { - if (e is UserUnauthorized) { - authBloc.add(SessionExpiredEvent()); - } else { - opBloc.add(FailOperation(message: fallbackMessage)); - } - } - - Future _dataUpdated(DataUpdated event, Emitter emit) async { - emit( - NoteLoaded( - notes: event.notes, - images: event.images, - templates: event.templates, - ), - ); - } - - Future _reset(ResetNotes event, Emitter emit) async { - emit.call(NoteInitial()); - } - - Future _removeImage( - RemoveImageNote event, - Emitter emit, - ) async { - opBloc.add(StartOperation()); - try { - await repository.deleteImage(event.deliveryId, event.objectId); - opBloc.add(FinishOperation()); - } catch (e, st) { - debugPrint("Fehler beim Löschen des Bildes: $e $st"); - _handleError(e, "Fehler beim Löschen des Bildes"); - } - } - - Future _upload(AddImageNote event, Emitter emit) async { - opBloc.add(StartOperation()); - try { - Uint8List imageBytes = await event.file.readAsBytes(); - await repository.addImage(event.deliveryId, imageBytes); - opBloc.add(FinishOperation()); - } catch (e, st) { - debugPrint("Fehler beim Hinzufügen des Bildes: $e $st"); - _handleError(e, "Fehler beim Hinzufügen des Bildes"); - } - } - - Future _load(LoadNote event, Emitter emit) async { - if (state is NoteLoaded || state is NoteLoading) { - return; - } - - emit.call(NoteLoading()); - - try { - await repository.loadNotes(event.delivery.id); - await repository.loadTemplates(); - } catch (e, st) { - debugPrint("Fehler beim Herunterladen der Notizen: $e $st"); - if (e is UserUnauthorized) { - authBloc.add(SessionExpiredEvent()); - return; - } - opBloc.add(FailOperation(message: "Notizen konnten nicht heruntergeladen werden.")); - emit.call(NoteLoadingFailed()); - } - } - - Future _add(AddNote event, Emitter emit) async { - opBloc.add(StartOperation()); - try { - await repository.addNote(event.deliveryId, event.note); - opBloc.add(FinishOperation()); - } catch (e, st) { - debugPrint("Fehler beim Hinzufügen der Notiz: $e $st"); - _handleError(e, "Fehler beim Hinzufügen der Notiz"); - } - } - - Future _edit(EditNote event, Emitter emit) async { - opBloc.add(StartOperation()); - try { - await repository.editNote(event.noteId, event.content); - opBloc.add(FinishOperation()); - } catch (e, st) { - debugPrint("Fehler beim Editieren der Notiz: $e $st"); - _handleError(e, "Fehler beim Editieren der Notiz"); - } - } - - Future _remove(RemoveNote event, Emitter emit) async { - opBloc.add(StartOperation()); - try { - await repository.deleteNote(event.noteId); - opBloc.add(FinishOperation()); - } catch (e, st) { - debugPrint("Fehler beim Löschen der Notiz: $e $st"); - _handleError(e, "Notiz konnte nicht gelöscht werden"); - } - } -} diff --git a/lib/feature/delivery/detail/bloc/note_event.dart b/lib/feature/delivery/detail/bloc/note_event.dart deleted file mode 100644 index 289b72d..0000000 --- a/lib/feature/delivery/detail/bloc/note_event.dart +++ /dev/null @@ -1,75 +0,0 @@ - -import 'package:hl_lieferservice/model/delivery.dart'; -import 'package:image_picker/image_picker.dart'; - -abstract class NoteEvent {} - -class LoadNote extends NoteEvent { - LoadNote({required this.delivery}); - - final Delivery delivery; -} - -class ResetNotes extends NoteEvent {} - -class AddNote extends NoteEvent { - AddNote({required this.note, required this.deliveryId}); - - final String note; - final String deliveryId; -} - -class AddNoteOffline extends NoteEvent { - AddNoteOffline({required this.note, required this.deliveryId, required this.noteId}); - - final String note; - final String noteId; - final String deliveryId; -} - -class RemoveNote extends NoteEvent { - RemoveNote({required this.noteId}); - - final String noteId; -} - -class EditNote extends NoteEvent { - EditNote({required this.content, required this.noteId}); - - final String noteId; - final String content; -} - -class AddImageNote extends NoteEvent { - AddImageNote({required this.file, required this.deliveryId}); - - final XFile file; - final String deliveryId; -} - -class RemoveImageNote extends NoteEvent { - RemoveImageNote({required this.objectId, required this.deliveryId}); - - final String objectId; - final String deliveryId; -} - -class NotesUpdated extends NoteEvent { - final List notes; - - NotesUpdated({required this.notes}); -} - -class ImageUpdated extends NoteEvent { - final List images; - - ImageUpdated({required this.images}); -} - -class DataUpdated extends NoteEvent { - final List images; - final List notes; - final List templates; - - DataUpdated({required this.images, required this.notes, required this.templates}); -} \ No newline at end of file diff --git a/lib/feature/delivery/detail/bloc/note_state.dart b/lib/feature/delivery/detail/bloc/note_state.dart deleted file mode 100644 index 113e403..0000000 --- a/lib/feature/delivery/detail/bloc/note_state.dart +++ /dev/null @@ -1,41 +0,0 @@ - -import 'package:hl_lieferservice/model/delivery.dart'; - -abstract class NoteState {} - -class NoteInitial extends NoteState {} - -class NoteLoading extends NoteState {} - -class NoteLoadingFailed extends NoteState {} - -class NoteLoadedBase extends NoteState { - NoteLoadedBase({ - required this.notes, - }); - - List notes; -} - -class NoteLoaded extends NoteLoadedBase { - NoteLoaded({ - this.templates, - this.images, - required super.notes, - }); - - List? templates; - List? images; - - NoteLoaded copyWith({ - List? notes, - List? templates, - List? images, - }) { - return NoteLoaded( - notes: notes ?? this.notes, - templates: templates ?? this.templates, - images: images ?? this.images, - ); - } -} diff --git a/lib/feature/delivery/detail/bloc/workflow_bloc.dart b/lib/feature/delivery/detail/bloc/workflow_bloc.dart new file mode 100644 index 0000000..328fc20 --- /dev/null +++ b/lib/feature/delivery/detail/bloc/workflow_bloc.dart @@ -0,0 +1,46 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'workflow_event.dart'; +import 'workflow_state.dart'; + +/// Bloc, der den Detail-Workflow einer einzelnen Lieferung trägt. +/// +/// Pro `DeliveryDetail`-Page-Push neu instanziert (per `BlocProvider` direkt +/// in der Page), Lifetime endet beim Pop. Strukturelle Persistenzen +/// (Notizen speichern, Artikel entfernen, Complete) gehen NICHT durch +/// diesen Bloc, sondern direkt am globalen `TourBloc` — der Workflow-Bloc +/// trägt nur Step-State + lokale Drafts. +class DeliveryWorkflowBloc + extends Bloc { + DeliveryWorkflowBloc({required String deliveryId}) + : super(DeliveryWorkflowState.initial(deliveryId)) { + on((e, emit) => emit(state.copyWith(step: e.step))); + on((e, emit) { + final next = state.step.index + 1; + if (next < WorkflowStep.values.length) { + emit(state.copyWith(step: WorkflowStep.values[next])); + } + }); + on((e, emit) { + final prev = state.step.index - 1; + if (prev >= 0) { + emit(state.copyWith(step: WorkflowStep.values[prev])); + } + }); + on((e, emit) { + final next = [ + ...state.pendingImageNotes, + PendingImageNote(file: e.file, pickedAt: DateTime.now()), + ]; + emit(state.copyWith(pendingImageNotes: next)); + }); + on((e, emit) { + if (e.index < 0 || e.index >= state.pendingImageNotes.length) return; + final next = [...state.pendingImageNotes]..removeAt(e.index); + emit(state.copyWith(pendingImageNotes: next)); + }); + on((e, emit) => emit( + state.copyWith(paymentMethodOverrideId: e.paymentMethodId), + )); + } +} diff --git a/lib/feature/delivery/detail/bloc/workflow_event.dart b/lib/feature/delivery/detail/bloc/workflow_event.dart new file mode 100644 index 0000000..4529bc9 --- /dev/null +++ b/lib/feature/delivery/detail/bloc/workflow_event.dart @@ -0,0 +1,43 @@ +import 'package:image_picker/image_picker.dart'; + +import 'workflow_state.dart'; + +/// Events des Detail-Workflow-Blocs. Strukturelle Aktionen (Notiz speichern, +/// Artikel entfernen, Lieferung abschließen) dispatchen NICHT hier, sondern +/// am `TourBloc` — der Workflow-Bloc kümmert sich nur um Step-Navigation +/// und Drafts. +sealed class DeliveryWorkflowEvent { + const DeliveryWorkflowEvent(); +} + +class WorkflowGoToStep extends DeliveryWorkflowEvent { + const WorkflowGoToStep(this.step); + final WorkflowStep step; +} + +class WorkflowNextStep extends DeliveryWorkflowEvent { + const WorkflowNextStep(); +} + +class WorkflowPreviousStep extends DeliveryWorkflowEvent { + const WorkflowPreviousStep(); +} + +// ─── Bild-Notiz-Drafts ────────────────────────────────────────────────── + +class WorkflowAddPendingImage extends DeliveryWorkflowEvent { + const WorkflowAddPendingImage(this.file); + final XFile file; +} + +class WorkflowRemovePendingImage extends DeliveryWorkflowEvent { + const WorkflowRemovePendingImage(this.index); + final int index; +} + +// ─── Payment-Auswahl ──────────────────────────────────────────────────── + +class WorkflowOverridePaymentMethod extends DeliveryWorkflowEvent { + const WorkflowOverridePaymentMethod({required this.paymentMethodId}); + final String? paymentMethodId; +} diff --git a/lib/feature/delivery/detail/bloc/workflow_state.dart b/lib/feature/delivery/detail/bloc/workflow_state.dart new file mode 100644 index 0000000..7c490a3 --- /dev/null +++ b/lib/feature/delivery/detail/bloc/workflow_state.dart @@ -0,0 +1,87 @@ +import 'package:image_picker/image_picker.dart'; + +/// Die 5 Steps der Auslieferungs-Detail-Page. Reihenfolge ≙ Index. +enum WorkflowStep { + info, + notes, + articles, + services, + summary, +} + +extension WorkflowStepX on WorkflowStep { + String get displayName => switch (this) { + WorkflowStep.info => 'Info', + WorkflowStep.notes => 'Notizen', + WorkflowStep.articles => 'Artikel & Gutschriften', + WorkflowStep.services => 'Services', + WorkflowStep.summary => 'Übersicht', + }; + + /// Kurze Bezeichnung für den Header-Step (Platz ist eng auf Mobilgeräten). + String get shortName => switch (this) { + WorkflowStep.info => 'Info', + WorkflowStep.notes => 'Notizen', + WorkflowStep.articles => 'Artikel', + WorkflowStep.services => 'Services', + WorkflowStep.summary => 'Übersicht', + }; +} + +/// Eine im Workflow geparkte Bild-Notiz, die noch nicht hochgeladen werden +/// kann — wartet auf den Foto-Upload-Endpoint. +class PendingImageNote { + const PendingImageNote({required this.file, required this.pickedAt}); + + /// Das vom `image_picker` zurückgegebene File-Handle. + final XFile file; + final DateTime pickedAt; +} + +/// State des Detail-Workflows. Ein State, ein Bloc — der Step-Wechsel, +/// die Drafts und die Payment-Auswahl liegen alle hier. So sieht jede +/// Step-Page denselben kohärenten Zustand und ein Step kann Daten aus +/// einem anderen lesen (z. B. Summary liest Article-Drafts). +class DeliveryWorkflowState { + const DeliveryWorkflowState({ + required this.deliveryId, + required this.step, + required this.pendingImageNotes, + required this.paymentMethodOverrideId, + }); + + factory DeliveryWorkflowState.initial(String deliveryId) => + DeliveryWorkflowState( + deliveryId: deliveryId, + step: WorkflowStep.info, + pendingImageNotes: const [], + paymentMethodOverrideId: null, + ); + + final String deliveryId; + final WorkflowStep step; + + /// Lokal gehaltene Bild-Notizen — solange kein Upload-Endpoint da ist. + final List pendingImageNotes; + + /// Wenn der Fahrer im Summary die Zahlungsmethode überschreibt, landet + /// die neue Id hier. `null` = Methode der Lieferung bleibt. + final String? paymentMethodOverrideId; + + DeliveryWorkflowState copyWith({ + WorkflowStep? step, + List? pendingImageNotes, + Object? paymentMethodOverrideId = _sentinel, + }) { + return DeliveryWorkflowState( + deliveryId: deliveryId, + step: step ?? this.step, + pendingImageNotes: pendingImageNotes ?? this.pendingImageNotes, + paymentMethodOverrideId: identical(paymentMethodOverrideId, _sentinel) + ? this.paymentMethodOverrideId + : paymentMethodOverrideId as String?, + ); + } +} + +const Object _sentinel = Object(); diff --git a/lib/feature/delivery/detail/exceptions.dart b/lib/feature/delivery/detail/exceptions.dart deleted file mode 100644 index 026c8b3..0000000 --- a/lib/feature/delivery/detail/exceptions.dart +++ /dev/null @@ -1 +0,0 @@ -class NoteImageAddException implements Exception {} \ No newline at end of file diff --git a/lib/feature/delivery/detail/model/note.dart b/lib/feature/delivery/detail/model/note.dart deleted file mode 100644 index 9cab48a..0000000 --- a/lib/feature/delivery/detail/model/note.dart +++ /dev/null @@ -1,10 +0,0 @@ - -import 'package:hl_lieferservice/model/article.dart'; -import 'package:hl_lieferservice/model/delivery.dart'; - -class NoteInformation { - NoteInformation({required this.note, this.article}); - - Note note; - Article? article; -} \ No newline at end of file diff --git a/lib/feature/delivery/detail/presentation/article/article_list.dart b/lib/feature/delivery/detail/presentation/article/article_list.dart deleted file mode 100644 index 68f17cb..0000000 --- a/lib/feature/delivery/detail/presentation/article/article_list.dart +++ /dev/null @@ -1,34 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:hl_lieferservice/feature/delivery/detail/presentation/article/article_list_item.dart'; -import 'package:hl_lieferservice/model/article.dart'; - -class ArticleList extends StatefulWidget { - const ArticleList({ - super.key, - required this.articles, - required this.deliveryId, - }); - - final List
articles; - final String deliveryId; - - @override - State createState() => _ArticleListState(); -} - -class _ArticleListState extends State { - @override - Widget build(BuildContext context) { - return ListView.separated( - shrinkWrap: true, - physics: NeverScrollableScrollPhysics(), - itemBuilder: - (context, index) => ArticleListItem( - article: widget.articles[index], - deliveryId: widget.deliveryId, - ), - separatorBuilder: (context, index) => const Divider(height: 0), - itemCount: widget.articles.length, - ); - } -} diff --git a/lib/feature/delivery/detail/presentation/article/article_list_item.dart b/lib/feature/delivery/detail/presentation/article/article_list_item.dart deleted file mode 100644 index 4d6824b..0000000 --- a/lib/feature/delivery/detail/presentation/article/article_list_item.dart +++ /dev/null @@ -1,97 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:hl_lieferservice/feature/delivery/detail/presentation/article/article_reset_scan_dialog.dart'; -import 'package:hl_lieferservice/feature/delivery/detail/presentation/article/article_unscan_dialog.dart'; -import 'package:hl_lieferservice/model/article.dart'; - -class ArticleListItem extends StatefulWidget { - const ArticleListItem({ - super.key, - required this.article, - required this.deliveryId, - }); - - final Article article; - final String deliveryId; - - @override - State createState() => _ArticleListItem(); -} - -class _ArticleListItem extends State { - Widget _leading() { - int amount = widget.article.getScannedAmount(); - Color? color; - Color? textColor; - - if (!widget.article.scannable) { - amount = widget.article.amount; - } - - if (amount == 0) { - color = Colors.redAccent; - textColor = Theme.of(context).colorScheme.onSecondary; - } - - return CircleAvatar( - backgroundColor: color, - child: Text("${amount}x", style: TextStyle(color: textColor)), - ); - } - - @override - Widget build(BuildContext context) { - Widget actionButton = IconButton.outlined( - style: ButtonStyle( - backgroundColor: WidgetStatePropertyAll(Colors.redAccent), - ), - onPressed: () { - showDialog( - context: context, - builder: - (context) => ArticleUnscanDialog( - article: widget.article, - deliveryId: widget.deliveryId, - ), - ); - }, - icon: Icon( - Icons.delete, - color: Theme.of(context).colorScheme.onSecondary, - ), - ); - - if ((widget.article.unscanned() && widget.article.scannable) || - !widget.article.scannable && widget.article.amount == 0) { - actionButton = IconButton.outlined( - style: ButtonStyle( - backgroundColor: WidgetStatePropertyAll(Colors.blueAccent), - ), - onPressed: () { - showDialog( - context: context, - builder: - (context) => ResetArticleAmountDialog( - article: widget.article, - deliveryId: widget.deliveryId, - ), - ); - }, - icon: Icon( - Icons.refresh, - color: Theme.of(context).colorScheme.onSecondary, - ), - ); - } - - return Padding( - padding: const EdgeInsets.all(0), - child: ListTile( - tileColor: Theme.of(context).colorScheme.surfaceContainerLowest, - title: Text(widget.article.name), - leading: _leading(), - subtitle: Text("Artikelnr. ${widget.article.articleNumber}"), - trailing: actionButton, - ), - ); - } -} diff --git a/lib/feature/delivery/detail/presentation/article/article_reset_scan_dialog.dart b/lib/feature/delivery/detail/presentation/article/article_reset_scan_dialog.dart deleted file mode 100644 index 5906e97..0000000 --- a/lib/feature/delivery/detail/presentation/article/article_reset_scan_dialog.dart +++ /dev/null @@ -1,119 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.dart'; -import 'package:hl_lieferservice/feature/delivery/bloc/tour_event.dart'; - -import '../../../../../model/article.dart'; - -class ResetArticleAmountDialog extends StatefulWidget { - const ResetArticleAmountDialog({ - super.key, - required this.article, - required this.deliveryId, - }); - - final Article article; - final String deliveryId; - - @override - State createState() => _ResetArticleAmountDialogState(); -} - -class _ResetArticleAmountDialogState extends State { - int _selectedAmount = 1; - - void _reset() { - String deliveryId = widget.deliveryId; - String articleId = widget.article.internalId.toString(); - - if (widget.article.scannable) { - context.read().add( - ResetScanAmountEvent( - articleId: widget.article.internalId.toString(), - deliveryId: widget.deliveryId, - ), - ); - } else { - debugPrint("ID: $articleId"); - debugPrint("AMOUNT :$_selectedAmount"); - - context.read().add( - SetArticleAmountEvent( - deliveryId: deliveryId, - articleId: articleId, - amount: _selectedAmount, - ), - ); - } - - Navigator.pop(context); - } - - Widget _amountSelection() { - final list = List.generate(3, (index) => index + 1); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text("Anzahl:", style: Theme.of(context).textTheme.labelLarge), - Padding( - padding: const EdgeInsets.all(10), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: - list - .map( - (index) => ChoiceChip( - label: Text("$index"), - selected: _selectedAmount == index, - onSelected: (bool selected) { - setState(() { - _selectedAmount = index; - }); - }, - ), - ) - .toList(), - ), - ), - ], - ); - } - - @override - Widget build(BuildContext context) { - return AlertDialog( - title: const Text("Anzahl Artikel zurücksetzen?"), - content: SizedBox( - height: MediaQuery.of(context).size.height * 0.25, - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Text("Wollen Sie die entfernten Artikel wieder hinzufügen?"), - !widget.article.scannable ? _amountSelection() : Container(), - Wrap( - spacing: 10, - runSpacing: 8, - alignment: WrapAlignment.spaceEvenly, - children: [ - FilledButton( - onPressed: _reset, - child: - widget.article.scannable - ? const Text("Zurücksetzen") - : const Text("Hinzufügen"), - ), - OutlinedButton( - onPressed: () { - Navigator.of(context).pop(); - }, - child: const Text("Abbrechen"), - ), - ], - ), - ], - ), - ), - ); - } -} diff --git a/lib/feature/delivery/detail/presentation/article/article_unscan_dialog.dart b/lib/feature/delivery/detail/presentation/article/article_unscan_dialog.dart deleted file mode 100644 index bff73f8..0000000 --- a/lib/feature/delivery/detail/presentation/article/article_unscan_dialog.dart +++ /dev/null @@ -1,179 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.dart'; -import 'package:hl_lieferservice/feature/delivery/bloc/tour_event.dart'; - -import '../../../../../model/article.dart'; - -class ArticleUnscanDialog extends StatefulWidget { - const ArticleUnscanDialog({ - super.key, - required this.article, - required this.deliveryId, - }); - - final String deliveryId; - final Article article; - - @override - State createState() => _ArticleUnscanDialogState(); -} - -class _ArticleUnscanDialogState extends State { - late TextEditingController unscanAmountController; - late TextEditingController unscanNoteController; - bool isValidText = false; - final _formKey = GlobalKey(); - - void _unscan() { - int amountToBeDeleted = int.parse(unscanAmountController.text); - String deliveryId = widget.deliveryId; - String articleId = widget.article.internalId.toString(); - String reason = unscanNoteController.text; - - if (widget.article.scannable) { - context.read().add( - UnscanArticleEvent( - deliveryId: deliveryId, - articleId: articleId, - newAmount: amountToBeDeleted, - reason: reason, - ), - ); - } else { - // If the article is not scannable we need to adjust the quantity of the article - // directly. - context.read().add( - SetArticleAmountEvent( - deliveryId: deliveryId, - articleId: articleId, - amount: widget.article.amount - amountToBeDeleted, - reason: reason - ), - ); - } - - Navigator.pop(context); - } - - @override - void initState() { - super.initState(); - - unscanAmountController = TextEditingController(text: "1"); - unscanNoteController = TextEditingController(text: ""); - - unscanNoteController.addListener(() { - setState(() { - isValidText = _isValid(); - }); - }); - - unscanAmountController.addListener(() { - setState(() { - isValidText = _isValid(); - }); - }); - } - - @override - void dispose() { - unscanAmountController.dispose(); - unscanNoteController.dispose(); - super.dispose(); - } - - bool _isValid() { - return _isAmountValid() && unscanNoteController.text.isNotEmpty; - } - - bool _isAmountValid() { - final amount = int.tryParse(unscanAmountController.text); - return amount != null && amount > 0 && amount <= widget.article.amount; - } - - @override - Widget build(BuildContext context) { - return AlertDialog( - title: const Text("Scan rückgängig machen"), - content: SizedBox( - width: double.infinity, - height: 350, - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - Text( - "Wollen Sie den Scanvorgang des Artikel '${widget.article.name}' rückgängig machen und den Artikel aus der Bestellung entfernen?", - ), - Form( - key: _formKey, - autovalidateMode: AutovalidateMode.always, - child: Column( - children: [ - TextFormField( - keyboardType: TextInputType.number, - inputFormatters: [ - FilteringTextInputFormatter.digitsOnly, - ], - validator: (text) { - if (text == null || text.isEmpty) { - return "Geben Sie eine Zahl ein"; - } - - final amount = int.tryParse(text); - if (amount == null || amount <= 0) { - return "Geben Sie eine gültige Zahl ein"; - } - - if (amount > widget.article.amount) { - return "Maximal ${widget.article.amount} möglich."; - } - - return null; - }, - controller: unscanAmountController, - decoration: const InputDecoration( - labelText: "Menge zu löschender Artikel", - ), - ), - TextFormField( - controller: unscanNoteController, - keyboardType: TextInputType.text, - decoration: const InputDecoration( - labelText: "Grund für die Entfernung", - ), - validator: (text) { - if (text == null || text.isEmpty) { - return "Geben Sie einen Grund an."; - } - - return null; - }, - ), - ], - ), - ), - Wrap( - spacing: 10, - runSpacing: 8, - alignment: WrapAlignment.spaceAround, - children: [ - FilledButton( - onPressed: isValidText ? _unscan : null, - child: const Text("Entfernen"), - ), - OutlinedButton( - onPressed: () { - Navigator.pop(context); - }, - child: const Text("Abbrechen"), - ), - ], - ), - ], - ), - ), - ); - } -} diff --git a/lib/feature/delivery/detail/presentation/delivery_detail_page.dart b/lib/feature/delivery/detail/presentation/delivery_detail_page.dart index ffbb309..e2d5e5d 100644 --- a/lib/feature/delivery/detail/presentation/delivery_detail_page.dart +++ b/lib/feature/delivery/detail/presentation/delivery_detail_page.dart @@ -1,173 +1,257 @@ -import 'dart:typed_data'; - -import 'package:easy_stepper/easy_stepper.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:hl_lieferservice/feature/delivery/detail/bloc/note_bloc.dart'; -import 'package:hl_lieferservice/feature/delivery/detail/bloc/note_event.dart'; -import 'package:hl_lieferservice/feature/delivery/detail/presentation/delivery_sign.dart'; -import 'package:hl_lieferservice/feature/delivery/detail/presentation/steps/step.dart'; + +import 'package:hl_lieferservice/domain/entity/delivery.dart'; +import 'package:hl_lieferservice/domain/entity/payment_method.dart'; +import 'package:hl_lieferservice/domain/entity/tour_details.dart'; import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.dart'; +import 'package:hl_lieferservice/feature/payment_methods/bloc/payment_methods_cubit.dart'; import 'package:hl_lieferservice/feature/delivery/bloc/tour_event.dart'; import 'package:hl_lieferservice/feature/delivery/bloc/tour_state.dart'; -import 'package:hl_lieferservice/model/delivery.dart'; +import 'package:hl_lieferservice/feature/delivery/detail/presentation/delivery_sign.dart'; +import 'package:hl_lieferservice/feature/delivery/detail/bloc/workflow_bloc.dart'; +import 'package:hl_lieferservice/feature/delivery/detail/bloc/workflow_event.dart'; +import 'package:hl_lieferservice/feature/delivery/detail/bloc/workflow_state.dart'; +import 'package:hl_lieferservice/feature/delivery/detail/presentation/steps/step_articles.dart'; +import 'package:hl_lieferservice/feature/delivery/detail/presentation/steps/step_info.dart'; +import 'package:hl_lieferservice/feature/delivery/detail/presentation/steps/step_notes.dart'; +import 'package:hl_lieferservice/feature/delivery/detail/presentation/steps/step_services.dart'; +import 'package:hl_lieferservice/feature/delivery/detail/presentation/steps/step_summary.dart'; -class DeliveryDetail extends StatefulWidget { - final String deliveryId; +// ───────────────────────────────────────────────────────────────────────── +// Backend-TODOs (siehe Roadmap unten in diesem File) +// ───────────────────────────────────────────────────────────────────────── +// +// Die folgenden UI-Features sind aktuell als lokaler Stub im +// `DeliveryWorkflowBloc` gebaut. Sie persistieren nichts am Server und +// gehen verloren, sobald die Detail-Page geschlossen wird: +// +// * **B1 Bild-Notizen**: Foto-Upload-Endpoint fehlt. Domain-Feld +// `DeliveryNote.image_attachment` existiert bereits als +// Storage-Key — wir brauchen einen Endpoint, der Multipart-Upload +// entgegennimmt und den Key zurückgibt, dann hier +// `tourBloc.add(AddDeliveryNote(imageAttachment: key))` aufrufen. +// +// * ~~B4 Zahlungsmethode beim Abschluss ändern~~ — ERLEDIGT: Die im +// Summary gewählte Methode (`paymentMethodOverrideId` im Workflow-State) +// reist beim Abschluss am `/complete`-Endpoint mit und wird atomar auf +// der Lieferung persistiert (Server prüft existiert + aktiv). +// +// * **B5 Unterschrift**: Signature-Pad-Bilder (Kunde + Fahrer) +// hochladen + auf der Lieferung speichern. Backend hat dafür weder +// Felder noch Endpoint. Explizit „nach der Session" verschoben. +// +// * **B6 Notiz-Templates**: Stammdaten (vordefinierte Notiz-Texte +// zum Auswählen). Im alten Stand schon UI-seitig im NoteAddDialog +// vorbereitet; aktuell zeigen wir nur das freie Textfeld. +// +// * **B7 `completeDelivery` im Frontend**: Repository-Methode + +// Bloc-Event fehlt. Backend-Endpoint existiert (parameterlos). +// Trigger: „Unterschreiben"-Button im Summary-Step — derzeit +// SnackBar-Stub. +// ───────────────────────────────────────────────────────────────────────── +/// Multi-Step Detail-Page einer einzelnen Lieferung. Hülle für 5 Steps; +/// jeder Step bekommt die aktuelle `Delivery` + `TourDetails` als Props, +/// damit die Steps keine eigenen Bloc-Subscriptions auf die Tour brauchen. +/// +/// Lifetime: die Page bringt ihren eigenen `DeliveryWorkflowBloc` mit +/// (siehe `BlocProvider` unten). Beim Pop wird der Bloc samt Drafts +/// disposed — gewollt, denn ein neuer Besuch der Detail-Page startet +/// frisch im Step „Info". +class DeliveryDetail extends StatelessWidget { const DeliveryDetail({super.key, required this.deliveryId}); + final String deliveryId; + @override - State createState() => _DeliveryDetailState(); + Widget build(BuildContext context) { + return BlocProvider( + create: (_) => DeliveryWorkflowBloc(deliveryId: deliveryId), + child: _DeliveryDetailScaffold(deliveryId: deliveryId), + ); + } } -class _DeliveryDetailState extends State { - late int _step; - late List _steps; +class _DeliveryDetailScaffold extends StatelessWidget { + const _DeliveryDetailScaffold({required this.deliveryId}); + + final String deliveryId; @override - void initState() { - super.initState(); - - // Reset Note BLOC - // otherwise the notes of the previously - // opened delivery would be loaded - context.read().add(ResetNotes()); - - // Initialize steps - _step = 0; - _steps = [ - EasyStep( - icon: const Icon(Icons.info), - customTitle: Text("Info", textAlign: TextAlign.center), - ), - EasyStep( - icon: const Icon(Icons.book), - customTitle: Text("Notizen", textAlign: TextAlign.center), - ), - EasyStep( - icon: const Icon(Icons.shopping_cart), - customTitle: Text("Artikel/Gutschriften", textAlign: TextAlign.center), - ), - EasyStep( - icon: const Icon(Icons.settings), - customTitle: Text("Optionen", textAlign: TextAlign.center), - ), - EasyStep( - icon: const Icon(Icons.check_box), - customTitle: Text( - "Überprüfen", - textAlign: TextAlign.center, - overflow: TextOverflow.clip, - ), - ), - ]; - } - - Widget _stepInfo() { - return DecoratedBox( - decoration: const BoxDecoration(), - child: SizedBox( - height: 115, - child: EasyStepper( - activeStep: _step, - showLoadingAnimation: false, - activeStepTextColor: Theme.of(context).primaryColor, - activeStepBorderType: BorderType.dotted, - finishedStepBorderType: BorderType.normal, - unreachedStepBorderType: BorderType.normal, - activeStepBackgroundColor: Colors.white, - borderThickness: 2, - internalPadding: 0.0, - enableStepTapping: true, - stepRadius: 25.0, - onStepReached: - (index) => { - setState(() { - _step = index; - }), - }, - steps: _steps, - ), - ), - ); - } - - Widget _stepMissingWarning() { - return Center( - child: Text("Kein Inhalt für den aktuellen Step $_step gefunden."), - ); - } - - void _clickForward() { - if (_step < _steps.length) { - setState(() { - _step += 1; - }); - } - } - - void _clickBack() { - if (_step > 0) { - setState(() { - _step -= 1; - }); - } - } - - void _openSignatureView(Delivery delivery) { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) { - return BlocProvider.value( - value: context.read(), - child: SignatureView(onSigned: _onSign, delivery: delivery), + Widget build(BuildContext context) { + final theme = Theme.of(context); + return BlocBuilder( + builder: (context, tourState) { + if (tourState is! TourLoaded) { + return const Scaffold( + body: Center(child: CircularProgressIndicator()), ); - }, - ), - ); - } - - void _onSign(Uint8List customer, Uint8List driver) async { - context.read().add( - FinishDeliveryEvent( - deliveryId: widget.deliveryId, - customerSignature: customer, - driverSignature: driver, - ), - ); - - Navigator.pop(context); - Navigator.pop(context); - } - - Widget _stepsNavigation(Delivery delivery) { - return SafeArea( - top: false, - child: SizedBox( - width: double.infinity, - height: 90, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - OutlinedButton( - onPressed: _step == 0 ? null : _clickBack, - child: const Text("zurück"), + } + final details = tourState.details; + final delivery = _findDelivery(details); + if (delivery == null) { + return Scaffold( + appBar: AppBar(title: const Text('Lieferung')), + body: Center( + child: Text('Lieferung $deliveryId nicht in der Tour gefunden.'), ), - Padding( - padding: const EdgeInsets.only(left: 20), - child: FilledButton( - onPressed: () { - if (_step == _steps.length - 1) { - _openSignatureView(delivery); - } else { - _clickForward(); - } - }, - child: - _step == _steps.length - 1 - ? const Text("Unterschreiben") - : const Text("weiter"), + ); + } + final customer = details.customerOf(delivery); + return Scaffold( + appBar: AppBar( + backgroundColor: theme.primaryColor, + foregroundColor: theme.colorScheme.onPrimary, + title: Text(customer?.name ?? 'Lieferung'), + ), + body: Column( + children: [ + const _StepHeader(), + const Divider(height: 1), + Expanded( + child: _StepBody(delivery: delivery, details: details), + ), + const Divider(height: 1), + _BottomNav(delivery: delivery, details: details), + ], + ), + ); + }, + ); + } + + Delivery? _findDelivery(TourDetails details) { + for (final d in details.deliveries) { + if (d.id == deliveryId) return d; + } + return null; + } +} + +// ─── Step-Header (Pills) ──────────────────────────────────────────────── + +class _StepHeader extends StatelessWidget { + const _StepHeader(); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + return Padding( + padding: const EdgeInsets.fromLTRB(8, 10, 8, 10), + child: Row( + children: [ + for (int i = 0; i < WorkflowStep.values.length; i++) ...[ + Expanded( + child: _StepPill( + index: i, + step: WorkflowStep.values[i], + isActive: state.step.index == i, + isPassed: state.step.index > i, + onTap: () => context + .read() + .add(WorkflowGoToStep(WorkflowStep.values[i])), + ), + ), + if (i < WorkflowStep.values.length - 1) + _StepConnector(isPassed: state.step.index > i), + ], + ], + ), + ); + }, + ); + } +} + +class _StepPill extends StatelessWidget { + const _StepPill({ + required this.index, + required this.step, + required this.isActive, + required this.isPassed, + required this.onTap, + }); + + final int index; + final WorkflowStep step; + final bool isActive; + final bool isPassed; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final primary = theme.colorScheme.primary; + + final Color circleColor; + final Color circleFg; + final Color labelColor; + final FontWeight labelWeight; + + if (isActive) { + circleColor = primary; + circleFg = theme.colorScheme.onPrimary; + labelColor = primary; + labelWeight = FontWeight.w700; + } else if (isPassed) { + circleColor = primary.withValues(alpha: 0.85); + circleFg = theme.colorScheme.onPrimary; + labelColor = primary; + labelWeight = FontWeight.w600; + } else { + circleColor = theme.colorScheme.surfaceContainerHighest; + circleFg = theme.colorScheme.onSurfaceVariant; + labelColor = theme.colorScheme.onSurfaceVariant; + labelWeight = FontWeight.w500; + } + + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Stack( + alignment: Alignment.center, + children: [ + Container( + width: 30, + height: 30, + decoration: BoxDecoration( + color: circleColor, + shape: BoxShape.circle, + border: isActive + ? Border.all(color: primary, width: 2) + : null, + ), + child: Center( + child: isPassed && !isActive + ? Icon(Icons.check, color: circleFg, size: 16) + : Text( + '${index + 1}', + style: TextStyle( + color: circleFg, + fontWeight: FontWeight.bold, + fontSize: 13, + ), + ), + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + step.shortName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: labelColor, + fontSize: 11, + fontWeight: labelWeight, ), ), ], @@ -175,37 +259,186 @@ class _DeliveryDetailState extends State { ), ); } +} + +class _StepConnector extends StatelessWidget { + const _StepConnector({required this.isPassed}); + final bool isPassed; @override Widget build(BuildContext context) { - return BlocBuilder( - builder: (context, state) { - Delivery? delivery; - if (state is TourLoaded) { - delivery = state.tour.deliveries.firstWhere( - (d) => d.id == widget.deliveryId, - ); - } + final theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.only(bottom: 18), + child: Container( + width: 10, + height: 2, + color: isPassed + ? theme.colorScheme.primary + : theme.colorScheme.surfaceContainerHighest, + ), + ); + } +} - return Scaffold( - appBar: AppBar(title: const Text("Auslieferungsdetails")), - body: delivery == null - ? const Center(child: CircularProgressIndicator()) - : Column( - children: [ - _stepInfo(), - const Divider(), - Expanded( - child: - StepFactory().make(_step, delivery) ?? - _stepMissingWarning(), - ), - ], - ), - bottomNavigationBar: - delivery == null ? null : _stepsNavigation(delivery), - ); +// ─── Step-Body Router ─────────────────────────────────────────────────── + +class _StepBody extends StatelessWidget { + const _StepBody({required this.delivery, required this.details}); + + final Delivery delivery; + final TourDetails details; + + @override + Widget build(BuildContext context) { + return BlocBuilder( + buildWhen: (prev, curr) => prev.step != curr.step, + builder: (context, state) { + switch (state.step) { + case WorkflowStep.info: + return StepInfo(delivery: delivery, details: details); + case WorkflowStep.notes: + return StepNotes(delivery: delivery, details: details); + case WorkflowStep.articles: + return StepArticles(delivery: delivery, details: details); + case WorkflowStep.services: + return StepServices(delivery: delivery, details: details); + case WorkflowStep.summary: + return StepSummary(delivery: delivery, details: details); + } }, ); } } + +// ─── Bottom-Navigation ────────────────────────────────────────────────── + +class _BottomNav extends StatelessWidget { + const _BottomNav({required this.delivery, required this.details}); + + final Delivery delivery; + final TourDetails details; + + /// Öffnet den zweistufigen Unterschrift-Flow (Kunde → Fahrer). Erst nach + /// beiden Unterschriften triggert die View den Backend-Abschluss via + /// `CompleteDelivery`; danach poppt sie zurück auf die Detail-Page, die + /// dann den `completed`-Status zeigt. + void _onSign(BuildContext context) { + final tourBloc = context.read(); + // Die im Summary-Step gewählte Zahlungsmethode lebt im Workflow-State. + // Beim Abschluss reisen wir sie mit ans Backend (atomar mit der Signatur); + // `null` = die am Beleg hinterlegte Methode bleibt. + final paymentMethodOverrideId = + context.read().state.paymentMethodOverrideId; + + // Offener Betrag = Warenwert − Anzahlung − Gutschrift (≥ 0). EXAKT die + // Formel aus StepSummary und dem Backend-Inkasso-Gate. + final creditEuros = + (details.creditOf(delivery.id)?.amountCents ?? 0) / 100.0; + final warenwert = + delivery.items.fold(0, (acc, item) => acc + item.lineTotal); + final open = (warenwert - delivery.prepaidAmount - creditEuros) + .clamp(0.0, double.infinity) + .toDouble(); + + // Effektive Methode (Override > Beleg) auflösen, um Vor-Ort-Inkasso + // (Bar/EC) von „Auf Rechnung" zu unterscheiden. + final effectiveMethodId = + paymentMethodOverrideId ?? delivery.paymentMethodId; + final pmState = context.read().state; + PaymentMethod? method; + if (pmState is PaymentMethodsLoaded) { + for (final m in pmState.methods) { + if (m.id == effectiveMethodId) { + method = m; + break; + } + } + } + // Inkasso-Pflicht: offener Betrag > 0 UND Bar/EC. „Auf Rechnung" → nein. + final requiresCollection = + open > 0 && (method?.code == 'cash' || method?.code == 'ec_card'); + + Navigator.of(context).push( + MaterialPageRoute( + builder: (routeContext) => SignatureView( + delivery: delivery, + details: details, + requiresCollection: requiresCollection, + openAmount: open, + paymentMethodLabel: method?.name ?? '', + onSigned: (result) { + tourBloc.add(CompleteDelivery( + deliveryId: delivery.id, + customerSignaturePng: result.customerSignaturePng, + driverSignaturePng: result.driverSignaturePng, + receiptConfirmed: result.receiptConfirmed, + notesAcknowledged: result.notesAcknowledged, + acknowledgedNoteIds: result.acknowledgedNoteIds, + paymentMethodId: paymentMethodOverrideId, + paymentCollected: result.paymentCollected, + )); + Navigator.of(routeContext).pop(); + }, + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return SafeArea( + top: false, + child: BlocBuilder( + builder: (context, state) { + final isFirst = state.step.index == 0; + final isLast = state.step.index == WorkflowStep.values.length - 1; + return Padding( + padding: const EdgeInsets.fromLTRB(16, 10, 16, 10), + child: Row( + children: [ + OutlinedButton.icon( + onPressed: isFirst + ? null + : () => context + .read() + .add(const WorkflowPreviousStep()), + icon: const Icon(Icons.arrow_back), + label: const Text('Zurück'), + ), + const Spacer(), + if (isLast) + // Unterschreiben/Abschließen nur bei aktiver Lieferung. + // Ist sie bereits abgeschlossen (oder pausiert/abgebrochen), + // bleibt der Button gesperrt. + Builder(builder: (context) { + final isActive = + delivery.state == DeliveryState.active; + final isCompleted = + delivery.state == DeliveryState.completed; + return FilledButton.icon( + onPressed: isActive ? () => _onSign(context) : null, + icon: Icon(isCompleted + ? Icons.check_circle_outline + : Icons.draw_outlined), + label: Text( + isCompleted ? 'Abgeschlossen' : 'Unterschreiben', + ), + ); + }) + else + FilledButton.icon( + onPressed: () => context + .read() + .add(const WorkflowNextStep()), + icon: const Icon(Icons.arrow_forward), + label: const Text('Weiter'), + ), + ], + ), + ); + }, + ), + ); + } +} diff --git a/lib/feature/delivery/detail/presentation/delivery_discount.dart b/lib/feature/delivery/detail/presentation/delivery_discount.dart deleted file mode 100644 index d6112ba..0000000 --- a/lib/feature/delivery/detail/presentation/delivery_discount.dart +++ /dev/null @@ -1,221 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.dart'; -import 'package:hl_lieferservice/model/delivery.dart'; - -import '../../bloc/tour_event.dart'; - -class DeliveryDiscount extends StatefulWidget { - const DeliveryDiscount({ - super.key, - this.discount, - required this.disabled, - required this.deliveryId, - }); - - final bool disabled; - final Discount? discount; - final String deliveryId; - - @override - State createState() => _DeliveryDiscountState(); -} - -class _DeliveryDiscountState extends State { - final int stepSize = 10; - - late TextEditingController _reasonController; - late bool _isReasonEmpty; - late bool _isUpdated; - late int _discountValue; - - @override - void initState() { - super.initState(); - - _reasonController = TextEditingController(text: widget.discount?.note); - _isReasonEmpty = _reasonController.text.isEmpty; - _reasonController.addListener(() { - setState(() { - _isReasonEmpty = _reasonController.text.isEmpty; - }); - }); - - _discountValue = - widget.discount?.article.getGrossPrice().floor().abs() ?? 0; - - _isUpdated = _discountValue > 0 && _reasonController.text.isNotEmpty; - } - - @override - void dispose() { - super.dispose(); - _reasonController.dispose(); - } - - bool _maximumReached() { - return _discountValue >= 150; - } - - bool _minimumReached() { - return _discountValue <= 0; - } - - Widget _incrementDiscount() { - return Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - IconButton.filled( - onPressed: - _minimumReached() || widget.disabled - ? null - : () { - setState(() { - if (_discountValue - stepSize >= 0) { - _discountValue -= stepSize; - } - }); - }, - icon: const Icon(Icons.remove), - style: ButtonStyle( - backgroundColor: - _minimumReached() || widget.disabled - ? WidgetStateProperty.all(Colors.grey) - : WidgetStateProperty.all(Colors.red), - ), - ), - Padding( - padding: const EdgeInsets.all(5), - child: Column( - children: [ - Text( - "${_discountValue.abs()}€", - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 18.0, - ), - ), - const Text("max. 150€", style: TextStyle(fontSize: 10.0)), - ], - ), - ), - IconButton.filled( - onPressed: - _maximumReached() || widget.disabled - ? null - : () { - setState(() { - _discountValue += stepSize; - }); - }, - icon: const Icon(Icons.add), - style: ButtonStyle( - backgroundColor: - _maximumReached() || widget.disabled - ? WidgetStateProperty.all(Colors.grey) - : WidgetStateProperty.all(Colors.green), - ), - ), - ], - ); - } - - void _resetValues() async { - setState(() { - _discountValue = 0; - _reasonController.clear(); - _isUpdated = false; - }); - - context.read().add( - RemoveDiscountEvent(deliveryId: widget.deliveryId), - ); - } - - void _updateValues() async { - if (_isUpdated) { - context.read().add( - UpdateDiscountEvent( - deliveryId: widget.deliveryId, - value: _discountValue, - reason: _reasonController.text, - ), - ); - } else { - context.read().add( - AddDiscountEvent( - deliveryId: widget.deliveryId, - value: _discountValue, - reason: _reasonController.text, - ), - ); - - setState(() { - setState(() { - _isUpdated = true; - }); - }); - } - } - - @override - Widget build(BuildContext context) { - return DecoratedBox( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.onSecondary, - ), - child: Padding( - padding: const EdgeInsets.all(20), - child: Form( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - "Betrag:", - style: TextStyle(fontWeight: FontWeight.bold), - ), - _incrementDiscount(), - const Padding( - padding: EdgeInsets.only(top: 10), - child: Text( - "Begründung:", - style: TextStyle(fontWeight: FontWeight.bold), - ), - ), - Padding( - padding: const EdgeInsets.only(bottom: 20), - child: TextFormField( - controller: _reasonController, - validator: (text) { - if (text == null || text.isEmpty) { - return "Begründung für Gutschrift notwendig."; - } - - return null; - }, - ), - ), - Wrap( - spacing: 10, - runSpacing: 8, - children: [ - FilledButton( - onPressed: - !_isReasonEmpty && _discountValue > 0 - ? _updateValues - : null, - child: const Text("Speichern"), - ), - OutlinedButton( - onPressed: _discountValue > 0 && widget.discount != null ? _resetValues : null, - child: const Text("Gutschrift entfernen"), - ), - ], - ), - ], - ), - ), - ), - ); - } -} diff --git a/lib/feature/delivery/detail/presentation/delivery_options.dart b/lib/feature/delivery/detail/presentation/delivery_options.dart deleted file mode 100644 index 83e830b..0000000 --- a/lib/feature/delivery/detail/presentation/delivery_options.dart +++ /dev/null @@ -1,121 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.dart'; -import 'package:hl_lieferservice/model/delivery.dart' as model; - -import '../../bloc/tour_event.dart'; - -class DeliveryOptionsView extends StatefulWidget { - const DeliveryOptionsView({ - super.key, - required this.options, - required this.deliveryId, - }); - - final List options; - final String deliveryId; - - @override - State createState() => _DeliveryOptionsViewState(); -} - -class _DeliveryOptionsViewState extends State { - late Map _controllers; - - @override - void initState() { - super.initState(); - - _controllers = {}; - for (final option in widget.options.where((option) => option.numerical)) { - _controllers[option.key] = TextEditingController(text: option.getValue().toString()); - } - } - - @override - void didUpdateWidget(covariant DeliveryOptionsView oldWidget) { - super.didUpdateWidget(oldWidget); - } - - void _update(model.DeliveryOption option, dynamic value) { - if (value is bool) { - context.read().add( - UpdateDeliveryOptionEvent( - key: option.key, - value: !value, - deliveryId: widget.deliveryId, - ), - ); - - return; - } - - context.read().add( - UpdateDeliveryOptionEvent( - key: option.key, - value: value, - deliveryId: widget.deliveryId, - ), - ); - } - - List _options() { - List boolOptions = - widget.options.where((option) => !option.numerical).map((option) { - return CheckboxListTile( - value: option.getValue(), - onChanged: (value) { - _update(option, option.getValue()); - }, - title: Text(option.display), - ); - }).toList(); - - List numericalOptions = - widget.options.where((option) => option.numerical).map((option) { - return Padding( - padding: const EdgeInsets.all(15), - child: TextFormField( - decoration: InputDecoration(labelText: option.display), - controller: _controllers[option.key], - keyboardType: TextInputType.number, - onTapOutside: (event) { - FocusScope.of(context).unfocus(); - _update(option, _controllers[option.key]?.text); - }, - textInputAction: TextInputAction.done, - onFieldSubmitted: (value) { - _update(option, value); - }, - ), - ); - }).toList(); - - return [ - Padding( - padding: const EdgeInsets.only(bottom: 5), - child: Text( - "Auswählbare Optionen", - style: Theme.of(context).textTheme.headlineSmall, - ), - ), - ...boolOptions, - Padding( - padding: const EdgeInsets.only(top: 10), - child: Text( - "Zahlenwerte", - style: Theme.of(context).textTheme.headlineSmall, - ), - ), - ...numericalOptions, - ]; - } - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.all(10), - child: ListView(children: _options()), - ); - } -} diff --git a/lib/feature/delivery/detail/presentation/delivery_sign.dart b/lib/feature/delivery/detail/presentation/delivery_sign.dart index de4cf0a..64572a4 100644 --- a/lib/feature/delivery/detail/presentation/delivery_sign.dart +++ b/lib/feature/delivery/detail/presentation/delivery_sign.dart @@ -1,56 +1,129 @@ import 'dart:typed_data'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:hl_lieferservice/feature/delivery/detail/bloc/note_bloc.dart'; -import 'package:hl_lieferservice/feature/delivery/detail/bloc/note_event.dart'; -import 'package:hl_lieferservice/feature/delivery/detail/bloc/note_state.dart'; import 'package:flutter/material.dart'; -import 'package:hl_lieferservice/model/delivery.dart'; import 'package:intl/intl.dart'; import 'package:signature/signature.dart'; -enum _SigningPhase { customerAcceptance, customerSignature, driverSignature } +import 'package:hl_lieferservice/domain/entity/delivery.dart'; +import 'package:hl_lieferservice/domain/entity/delivery_note.dart'; +import 'package:hl_lieferservice/domain/entity/tour_details.dart'; +/// Daten, die der Abschluss-Flow an den Aufrufer zurückgibt: beide +/// Unterschriften als PNG plus die dokumentierten Bestätigungen des Kunden. +class SignatureResult { + const SignatureResult({ + required this.customerSignaturePng, + required this.driverSignaturePng, + required this.receiptConfirmed, + required this.notesAcknowledged, + required this.acknowledgedNoteIds, + required this.paymentCollected, + }); + + final Uint8List customerSignaturePng; + final Uint8List driverSignaturePng; + final bool receiptConfirmed; + final bool notesAcknowledged; + final List acknowledgedNoteIds; + + /// Fahrer hat das Inkasso (Bar/EC) des offenen Betrags bestätigt. `false`, + /// wenn kein Inkasso anfiel (offen == 0 oder „Auf Rechnung"). + final bool paymentCollected; +} + +/// Mehrstufiger Unterschrift-Flow zum Abschließen einer Lieferung. +/// +/// Stufe 0 (Fahrer, optional): nur wenn beim Abschluss ein offener Betrag +/// per Vor-Ort-Inkasso (Bar/EC) zu kassieren ist ([requiresCollection]). +/// Der Fahrer bestätigt, dass der Betrag erhalten/abgerechnet wurde — VOR +/// beiden Unterschriften. +/// Stufe 1 (Kunde): sieht die Anmerkungen zur Lieferung, hakt zwei +/// Bestätigungen ab (Anmerkungen-Kenntnisnahme — nur Pflicht, wenn Notizen +/// vorhanden; Empfangsbestätigung — immer Pflicht) und unterschreibt. +/// Stufe 2 (Fahrer): unterschreibt. +/// +/// Erst nach beiden Unterschriften ruft die View [onSigned] mit dem +/// vollständigen [SignatureResult] auf — der Aufrufer triggert dann den +/// Backend-Abschluss und schließt die Seite. class SignatureView extends StatefulWidget { const SignatureView({ super.key, - required this.onSigned, required this.delivery, + required this.details, + required this.onSigned, + this.requiresCollection = false, + this.openAmount = 0, + this.paymentMethodLabel = '', }); final Delivery delivery; + final TourDetails details; + final void Function(SignatureResult result) onSigned; - /// Callback that is called when the user has signed. - /// The parameter stores the path to the image file of the signature. - final void Function( - Uint8List customerSignaturePng, - Uint8List driverSignaturePng, - ) - onSigned; + /// Offener Betrag muss vor Ort kassiert werden (offen > 0 UND Bar/EC). + /// Schaltet Stufe 0 (Inkasso-Bestätigung) frei. + final bool requiresCollection; + + /// Offener Betrag in Euro (nur für die Anzeige in Stufe 0). + final double openAmount; + + /// Anzeigename der Zahlungsmethode (z. B. „Bar", „EC-Karte"). + final String paymentMethodLabel; @override - State createState() => _SignatureViewState(); + State createState() => _SignatureViewState(); } +/// Stufen des Abschluss-Flows. +enum _SignStage { payment, customer, driver } + class _SignatureViewState extends State { + static const String _receiptText = + 'Ich bestätige, dass ich die Ware im ordnungsgemäßen Zustand erhalten ' + 'habe und, dass die Aufstell- und Einbauarbeiten korrekt durchgeführt ' + 'wurden.'; + static const String _notesText = + 'Ich nehme die oben genannten Anmerkungen zur Lieferung zur Kenntnis.'; + final SignatureController _customerController = SignatureController( - penStrokeWidth: 5, + penStrokeWidth: 3, penColor: Colors.black, exportBackgroundColor: Colors.white, ); - final SignatureController _driverController = SignatureController( - penStrokeWidth: 5, + penStrokeWidth: 3, penColor: Colors.black, exportBackgroundColor: Colors.white, ); - _SigningPhase _phase = _SigningPhase.customerAcceptance; + late final List _notes; + late _SignStage _stage; + bool _paymentConfirmed = false; + bool _receiptAccepted = false; + bool _notesAccepted = false; + bool _customerEmpty = true; + bool _driverEmpty = true; + + bool get _notesEmpty => _notes.isEmpty; @override void initState() { super.initState(); - context.read().add(LoadNote(delivery: widget.delivery)); + // Inkasso-Bestätigung (Stufe 0) nur wenn gefordert, sonst direkt zum Kunden. + _stage = + widget.requiresCollection ? _SignStage.payment : _SignStage.customer; + _notes = widget.details.notesByDeliveryId[widget.delivery.id] ?? + const []; + _customerController.addListener(() { + if (_customerEmpty != _customerController.isEmpty) { + setState(() => _customerEmpty = _customerController.isEmpty); + } + }); + _driverController.addListener(() { + if (_driverEmpty != _driverController.isEmpty) { + setState(() => _driverEmpty = _driverController.isEmpty); + } + }); } @override @@ -60,314 +133,333 @@ class _SignatureViewState extends State { super.dispose(); } - void _onAcceptanceDone() { - setState(() => _phase = _SigningPhase.customerSignature); - } + bool get _customerStepValid => + _receiptAccepted && (_notesAccepted || _notesEmpty) && !_customerEmpty; - void _onCustomerSigned() { - setState(() => _phase = _SigningPhase.driverSignature); - } - - Future _onDriverSigned() async { - widget.onSigned( - (await _customerController.toPngBytes())!, - (await _driverController.toPngBytes())!, - ); - } - - @override - Widget build(BuildContext context) { - return switch (_phase) { - _SigningPhase.customerAcceptance => _AcceptanceStep( - onContinue: _onAcceptanceDone, - ), - _SigningPhase.customerSignature => _SignaturePadStep( - controller: _customerController, - delivery: widget.delivery, - appBarTitle: "Unterschrift des Kunden", - buttonLabel: "Weiter", - onContinue: _onCustomerSigned, - ), - _SigningPhase.driverSignature => _SignaturePadStep( - controller: _driverController, - delivery: widget.delivery, - appBarTitle: "Unterschrift des Fahrers", - buttonLabel: "Absenden", - onContinue: _onDriverSigned, - ), - }; - } -} - -class _AcceptanceStep extends StatefulWidget { - const _AcceptanceStep({required this.onContinue}); - - final VoidCallback onContinue; - - @override - State<_AcceptanceStep> createState() => _AcceptanceStepState(); -} - -class _AcceptanceStepState extends State<_AcceptanceStep> { - bool _customerAccepted = false; - bool _noteAccepted = false; - - Widget _notesContent(NoteState noteState) { - if (noteState is! NoteLoaded) { - return const SizedBox( - width: double.infinity, - child: Center(child: CircularProgressIndicator()), - ); + /// Ist der Primär-Button auf der aktuellen Stufe aktiv? + bool get _stageValid { + switch (_stage) { + case _SignStage.payment: + return _paymentConfirmed; + case _SignStage.customer: + return _customerStepValid; + case _SignStage.driver: + return !_driverEmpty; } - if (noteState.notes.isEmpty) { - return const SizedBox( - width: double.infinity, - child: Center(child: Text("Keine Notizen vorhanden")), - ); - } - return ListView.separated( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemBuilder: (context, index) { - return ListTile( - leading: const Icon(Icons.event_note_outlined), - title: Text(noteState.notes[index].content), - contentPadding: const EdgeInsets.all(20), - tileColor: Theme.of(context).colorScheme.onSecondary, - ); - }, - separatorBuilder: (context, index) => const Divider(height: 0), - itemCount: noteState.notes.length, - ); } - Widget _notes(NoteState noteState) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.only(bottom: 15), - child: Text( - "Notizen", - style: Theme.of(context).textTheme.headlineSmall, - ), - ), - _notesContent(noteState), - const Padding(padding: EdgeInsets.only(top: 25), child: Divider()), - ], - ); - } - - @override - Widget build(BuildContext context) { - return BlocBuilder( - builder: (context, noteState) { - final notesEmpty = switch (noteState) { - NoteLoadedBase(notes: final ns) => ns.isEmpty, - _ => true, - }; - final isButtonEnabled = - _customerAccepted && (_noteAccepted || notesEmpty); - - return Scaffold( - appBar: AppBar(title: const Text("Unterschrift des Kunden")), - body: Padding( - padding: const EdgeInsets.all(20.0), - child: ListView( - children: [ - Padding( - padding: const EdgeInsets.only(top: 25, bottom: 0), - child: _notes(noteState), - ), - Padding( - padding: const EdgeInsets.only(top: 25.0, bottom: 0), - child: Row( - children: [ - Checkbox( - value: _noteAccepted, - onChanged: notesEmpty - ? null - : (value) { - setState(() { - _noteAccepted = value!; - }); - }, - ), - Flexible( - child: InkWell( - onTap: notesEmpty - ? null - : () { - setState(() { - _noteAccepted = !_noteAccepted; - }); - }, - child: Text( - "Ich nehme die oben genannten Anmerkungen zur Lieferung zur Kenntnis.", - overflow: TextOverflow.fade, - ), - ), - ), - ], - ), - ), - Padding( - padding: const EdgeInsets.only(top: 25.0, bottom: 10.0), - child: Row( - children: [ - Checkbox( - value: _customerAccepted, - onChanged: (value) { - setState(() { - _customerAccepted = value!; - }); - }, - ), - Flexible( - child: InkWell( - child: Text( - "Ware in ordnungsgemäßem Zustand erhalten. Aufstell- und Einbauarbeiten wurden korrekt durchgeführt", - overflow: TextOverflow.fade, - ), - onTap: () { - setState(() { - _customerAccepted = !_customerAccepted; - }); - }, - ), - ), - ], - ), - ), - ], - ), - ), - bottomNavigationBar: SafeArea( - top: false, - child: SizedBox( - width: double.infinity, - height: 90, - child: Center( - child: FilledButton( - onPressed: isButtonEnabled ? widget.onContinue : null, - child: const Text("Unterschreiben"), - ), - ), - ), + Future _onPrimaryPressed() async { + switch (_stage) { + case _SignStage.payment: + setState(() => _stage = _SignStage.customer); + return; + case _SignStage.customer: + setState(() => _stage = _SignStage.driver); + return; + case _SignStage.driver: + final customerPng = await _customerController.toPngBytes(); + final driverPng = await _driverController.toPngBytes(); + if (customerPng == null || driverPng == null) return; + widget.onSigned( + SignatureResult( + customerSignaturePng: customerPng, + driverSignaturePng: driverPng, + receiptConfirmed: _receiptAccepted, + notesAcknowledged: _notesEmpty ? false : _notesAccepted, + acknowledgedNoteIds: + _notesEmpty ? const [] : _notes.map((n) => n.id).toList(), + paymentCollected: widget.requiresCollection && _paymentConfirmed, ), ); - }, - ); - } -} - -class _SignaturePadStep extends StatefulWidget { - const _SignaturePadStep({ - required this.controller, - required this.delivery, - required this.appBarTitle, - required this.buttonLabel, - required this.onContinue, - }); - - final SignatureController controller; - final Delivery delivery; - final String appBarTitle; - final String buttonLabel; - final VoidCallback onContinue; - - @override - State<_SignaturePadStep> createState() => _SignaturePadStepState(); -} - -class _SignaturePadStepState extends State<_SignaturePadStep> { - bool _isEmpty = true; - late final VoidCallback _listener; - - @override - void initState() { - super.initState(); - _isEmpty = widget.controller.isEmpty; - _listener = () { - if (_isEmpty != widget.controller.isEmpty) { - setState(() { - _isEmpty = widget.controller.isEmpty; - }); - } - }; - widget.controller.addListener(_listener); + } } - @override - void dispose() { - widget.controller.removeListener(_listener); - super.dispose(); + String get _paymentText { + final amount = widget.openAmount.toStringAsFixed(2).replaceAll('.', ','); + final via = widget.paymentMethodLabel.isEmpty + ? '' + : ' per ${widget.paymentMethodLabel}'; + return 'Ich bestätige, dass der offene Betrag von $amount €$via ' + 'erhalten bzw. abgerechnet wurde.'; } @override Widget build(BuildContext context) { - final formattedDate = DateFormat("dd.MM.yyyy").format(DateTime.now()); + final theme = Theme.of(context); + final customer = widget.details.customerOf(widget.delivery); + final date = DateFormat('dd.MM.yyyy').format(DateTime.now()); + final isPayment = _stage == _SignStage.payment; + final isDriver = _stage == _SignStage.driver; + + final String title; + switch (_stage) { + case _SignStage.payment: + title = 'Zahlung bestätigen'; + case _SignStage.customer: + title = 'Unterschrift des Kunden'; + case _SignStage.driver: + title = 'Unterschrift des Fahrers'; + } return Scaffold( - appBar: AppBar(title: Text(widget.appBarTitle)), - body: Padding( - padding: const EdgeInsets.all(20.0), + appBar: AppBar(title: Text(title)), + body: SafeArea( child: ListView( + padding: const EdgeInsets.all(16), children: [ - SizedBox( - width: double.infinity, - height: MediaQuery.of(context).size.height * 0.75, - child: DecoratedBox( - decoration: const BoxDecoration(color: Colors.white), - child: Padding( - padding: const EdgeInsets.all(20.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Text( - "Lieferung an: ${widget.delivery.customer.name}", - style: const TextStyle( - fontWeight: FontWeight.bold, - ), - ), - Expanded( - child: Signature( - controller: widget.controller, - backgroundColor: Colors.white, - ), - ), - ], - ), - ), - const Divider(), - Text( - "${widget.delivery.customer.address.city}, den $formattedDate", - ), - ], + if (isPayment) ...[ + // Stufe 0 — Fahrer kassiert (Bar/EC) und bestätigt VOR den + // Unterschriften. + _PaymentDueCard( + openAmount: widget.openAmount, + methodLabel: widget.paymentMethodLabel, + ), + const SizedBox(height: 16), + _ConfirmTile( + value: _paymentConfirmed, + enabled: true, + label: _paymentText, + onChanged: (v) => setState(() => _paymentConfirmed = v), + ), + ] else ...[ + if (!isDriver) ...[ + _NotesSection(notes: _notes), + const SizedBox(height: 16), + _ConfirmTile( + value: _notesAccepted, + enabled: !_notesEmpty, + label: _notesText, + onChanged: (v) => setState(() => _notesAccepted = v), + ), + _ConfirmTile( + value: _receiptAccepted, + enabled: true, + label: _receiptText, + onChanged: (v) => setState(() => _receiptAccepted = v), + ), + const SizedBox(height: 16), + ], + Text( + 'Lieferung an: ${customer?.name ?? '⟨Unbekannter Kunde⟩'}', + style: theme.textTheme.titleSmall + ?.copyWith(fontWeight: FontWeight.w700), + ), + const SizedBox(height: 8), + _SignaturePad( + controller: isDriver ? _driverController : _customerController, + onClear: () => + (isDriver ? _driverController : _customerController) + .clear(), + ), + const SizedBox(height: 4), + Text( + '${customer?.address.city ?? ''}, den $date', + style: theme.textTheme.bodySmall, + ), + ], + const SizedBox(height: 24), + FilledButton.icon( + onPressed: _stageValid ? _onPrimaryPressed : null, + icon: Icon(isDriver ? Icons.check : Icons.arrow_forward), + label: Text(isDriver ? 'Abschließen' : 'Weiter'), + ), + ], + ), + ), + ); + } +} + +// ─── Inkasso-Hinweis (Stufe 0) ────────────────────────────────────────────── + +class _PaymentDueCard extends StatelessWidget { + const _PaymentDueCard({required this.openAmount, required this.methodLabel}); + + final double openAmount; + final String methodLabel; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final amount = openAmount.toStringAsFixed(2).replaceAll('.', ','); + return Card( + margin: EdgeInsets.zero, + color: theme.colorScheme.primary.withValues(alpha: 0.07), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Offener Betrag', + style: theme.textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.w700), + ), + const SizedBox(height: 8), + Row( + children: [ + Icon(Icons.payments_outlined, color: theme.colorScheme.primary), + const SizedBox(width: 10), + Text( + '$amount €', + style: theme.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w800, + color: theme.colorScheme.primary, ), ), + const Spacer(), + if (methodLabel.isNotEmpty) + Chip( + label: Text(methodLabel), + visualDensity: VisualDensity.compact, + ), + ], + ), + const SizedBox(height: 8), + Text( + 'Bitte den Betrag bar entgegennehmen oder über das EC-Gerät ' + 'abrechnen und anschließend bestätigen.', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, ), ), ], ), ), - bottomNavigationBar: SafeArea( - top: false, - child: SizedBox( - width: double.infinity, - height: 90, - child: Center( - child: FilledButton( - onPressed: _isEmpty ? null : widget.onContinue, - child: Text(widget.buttonLabel), - ), - ), - ), - ), + ); + } +} + +// ─── Notizen-Block ──────────────────────────────────────────────────────── + +class _NotesSection extends StatelessWidget { + const _NotesSection({required this.notes}); + + final List notes; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Anmerkungen zur Lieferung', + style: + theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700), + ), + const SizedBox(height: 8), + if (notes.isEmpty) + Card( + margin: EdgeInsets.zero, + child: Padding( + padding: const EdgeInsets.all(16), + child: Text( + 'Keine Anmerkungen vorhanden.', + style: TextStyle( + fontStyle: FontStyle.italic, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ) + else + Card( + margin: EdgeInsets.zero, + child: Column( + children: [ + for (int i = 0; i < notes.length; i++) ...[ + ListTile( + leading: Icon( + notes[i].imageAttachment != null + ? (notes[i].imageAttachmentDeleted + ? Icons.picture_as_pdf_outlined + : Icons.photo_outlined) + : Icons.event_note_outlined, + color: theme.colorScheme.primary, + ), + title: Text( + notes[i].text?.trim().isNotEmpty == true + ? notes[i].text!.trim() + : (notes[i].imageAttachmentDeleted + ? 'Bild im Lieferbericht enthalten' + : 'Foto-Anhang'), + ), + ), + if (i < notes.length - 1) + const Divider(height: 1, indent: 16, endIndent: 16), + ], + ], + ), + ), + ], + ); + } +} + +// ─── Bestätigungs-Checkbox ────────────────────────────────────────────────── + +class _ConfirmTile extends StatelessWidget { + const _ConfirmTile({ + required this.value, + required this.enabled, + required this.label, + required this.onChanged, + }); + + final bool value; + final bool enabled; + final String label; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return CheckboxListTile( + value: value, + onChanged: enabled ? (v) => onChanged(v ?? false) : null, + controlAffinity: ListTileControlAffinity.leading, + contentPadding: EdgeInsets.zero, + title: Text(label, style: const TextStyle(fontSize: 14)), + ); + } +} + +// ─── Signatur-Pad ─────────────────────────────────────────────────────────── + +class _SignaturePad extends StatelessWidget { + const _SignaturePad({required this.controller, required this.onClear}); + + final SignatureController controller; + final VoidCallback onClear; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Container( + height: 220, + decoration: BoxDecoration( + color: Colors.white, + border: Border.all(color: theme.colorScheme.outline), + borderRadius: BorderRadius.circular(8), + ), + clipBehavior: Clip.antiAlias, + child: Signature( + controller: controller, + backgroundColor: Colors.white, + ), + ), + Align( + alignment: Alignment.centerRight, + child: TextButton.icon( + onPressed: onClear, + icon: const Icon(Icons.undo), + label: const Text('Löschen'), + ), + ), + ], ); } } diff --git a/lib/feature/delivery/detail/presentation/delivery_summary.dart b/lib/feature/delivery/detail/presentation/delivery_summary.dart deleted file mode 100644 index 9db9e5a..0000000 --- a/lib/feature/delivery/detail/presentation/delivery_summary.dart +++ /dev/null @@ -1,177 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.dart'; -import 'package:hl_lieferservice/feature/delivery/bloc/tour_event.dart'; -import 'package:hl_lieferservice/feature/delivery/bloc/tour_state.dart'; -import 'package:hl_lieferservice/model/delivery.dart'; - -import '../../../../model/tour.dart'; - -class DeliverySummary extends StatefulWidget { - const DeliverySummary({super.key, required this.delivery}); - - final Delivery delivery; - - @override - State createState() => _DeliverySummaryState(); -} - -class _DeliverySummaryState extends State { - late List _paymentMethods; - - @override - void initState() { - super.initState(); - - final tourState = context.read().state as TourLoaded; - _paymentMethods = [...tourState.paymentOptions]; - - if (!_paymentMethods.any( - (payment) => payment.id == widget.delivery.payment.id, - )) { - _paymentMethods.add(widget.delivery.payment); - } - } - - Widget _deliveredArticles() { - List items = - widget.delivery - .getDeliveredArticles() - .map( - (article) => DecoratedBox( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.onSecondary, - ), - child: ListTile( - title: Text(article.name), - subtitle: Text("Artikelnr. ${article.articleNumber}"), - trailing: Text( - "${article.scannable ? article.getGrossPriceScanned().toStringAsFixed(2) : article.getGrossPrice().toStringAsFixed(2)}€", - ), - leading: CircleAvatar( - child: Text( - "${article.scannable ? article.scannedAmount : article.amount}x", - ), - ), - ), - ), - ) - .toList(); - items.add( - DecoratedBox( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.onSecondary, - ), - child: ListTile( - title: const Text( - "Gesamtsumme:", - style: TextStyle(fontWeight: FontWeight.bold), - ), - trailing: Text( - "${widget.delivery.getGrossPrice().toStringAsFixed(2)}€", - style: TextStyle(fontWeight: FontWeight.bold), - ), - ), - ), - ); - - return ListView( - shrinkWrap: true, - physics: NeverScrollableScrollPhysics(), - children: items, - ); - } - - Widget _paymentOptions() { - List entries = - _paymentMethods - .map( - (payment) => DropdownMenuEntry( - value: payment.id, - label: "${payment.description} (${payment.shortcode})", - ), - ) - .toList(); - - return DropdownMenu( - dropdownMenuEntries: entries, - initialSelection: widget.delivery.payment.id, - onSelected: (id) { - context.read().add( - UpdateSelectedPaymentMethodEvent( - deliveryId: widget.delivery.id, - payment: _paymentMethods.firstWhere((payment) => payment.id == id), - ), - ); - }, - ); - } - - Widget _paymentDone() { - return DecoratedBox( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.onSecondary, - ), - child: Column( - children: [ - ListTile( - title: const Text( - "Bei Bestellung bezahlt:", - style: TextStyle(fontWeight: FontWeight.bold), - ), - trailing: Text("${widget.delivery.prepayment.toStringAsFixed(2)}€"), - ), - ListTile( - title: const Text( - "Offener Betrag:", - style: TextStyle(fontWeight: FontWeight.bold), - ), - trailing: Text( - "${widget.delivery.getOpenPrice().toStringAsFixed(2)}€", - style: TextStyle(fontWeight: FontWeight.w900, color: Colors.red), - ), - ), - ], - ), - ); - } - - @override - Widget build(BuildContext context) { - final insets = EdgeInsets.all(10); - - return Padding( - padding: const EdgeInsets.all(10), - child: ListView( - children: [ - Text( - "Ausgelieferte Artikel", - style: Theme.of(context).textTheme.headlineSmall, - ), - - Padding(padding: insets, child: _deliveredArticles()), - - Padding( - padding: const EdgeInsets.only(top: 10), - child: Text( - "Geleistete Zahlung", - style: Theme.of(context).textTheme.headlineSmall, - ), - ), - - Padding(padding: insets, child: _paymentDone()), - - Padding( - padding: const EdgeInsets.only(top: 10), - child: Text( - "Zahlungsmethode", - style: Theme.of(context).textTheme.headlineSmall, - ), - ), - - Padding(padding: insets, child: _paymentOptions()), - ], - ), - ); - } -} diff --git a/lib/feature/delivery/detail/presentation/note/note_add_dialog.dart b/lib/feature/delivery/detail/presentation/note/note_add_dialog.dart deleted file mode 100644 index daa50f8..0000000 --- a/lib/feature/delivery/detail/presentation/note/note_add_dialog.dart +++ /dev/null @@ -1,149 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:collection/collection.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:hl_lieferservice/feature/delivery/detail/bloc/note_bloc.dart'; -import 'package:hl_lieferservice/feature/delivery/detail/bloc/note_event.dart'; -import 'package:hl_lieferservice/model/delivery.dart'; - -class NoteAddDialog extends StatefulWidget { - final String delivery; - final List templates; - - const NoteAddDialog({ - super.key, - required this.delivery, - required this.templates, - }); - - @override - State createState() => _NoteAddDialogState(); -} - -class _NoteAddDialogState extends State { - final _noteController = TextEditingController(); - final _noteSelectionController = TextEditingController(); - late FocusNode _noteFieldFocusNode; - bool _isCustomNotesEmpty = true; - - @override - void initState() { - super.initState(); - - _noteFieldFocusNode = FocusNode(); - - _noteController.addListener(() { - setState(() { - _isCustomNotesEmpty = _noteController.text.isEmpty; - }); - }); - } - - void _onSave() { - String content = _noteController.text; - - context.read().add( - AddNote(note: content, deliveryId: widget.delivery), - ); - - Navigator.pop(context); - } - - @override - Widget build(BuildContext context) { - return Dialog( - // Default Dialog.insetPadding eats 80 dp horizontally on phones, leaving - // too little room for two side-by-side buttons on narrow devices like - // the Samsung A16F. Shrinking the inset gives back ~64 dp. - insetPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 24), - child: SizedBox( - width: MediaQuery.of(context).size.width, - height: MediaQuery.of(context).size.height * 0.6, - child: Padding( - padding: const EdgeInsets.all(20), - child: ListView( - //mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - "Notiz hinzufügen", - style: Theme.of(context).textTheme.headlineSmall, - ), - IconButton( - onPressed: () { - Navigator.of(context).pop(); - }, - icon: Icon(Icons.close), - ), - ], - ), - Padding( - padding: const EdgeInsets.only(bottom: 10.0, top: 20), - child: DropdownMenu( - controller: _noteSelectionController, - onSelected: (int? value) { - setState(() { - _noteController.text = - widget.templates[value!].content; - }); - }, - width: double.infinity, - label: const Text("Notiz auswählen"), - dropdownMenuEntries: - widget.templates - .mapIndexed( - (i, note) => - DropdownMenuEntry(value: i, label: note.title), - ) - .toList(), - ), - ), - const Padding( - padding: EdgeInsets.only(top: 0.0, bottom: 0.0), - child: Center(child: Text("oder")), - ), - Padding( - padding: const EdgeInsets.only(top: 10.0, bottom: 20.0), - child: TextFormField( - onTapOutside: (_) { _noteFieldFocusNode.unfocus(); }, - controller: _noteController, - textInputAction: TextInputAction.done, - onFieldSubmitted: (_) {_noteFieldFocusNode.unfocus();}, - focusNode: _noteFieldFocusNode, - decoration: const InputDecoration( - labelText: "Eigene Notiz", - border: OutlineInputBorder(), - ), - minLines: 8, - maxLines: 10, - ), - ), - Wrap( - spacing: 10, - runSpacing: 8, - children: [ - FilledButton( - onPressed: - _noteSelectionController.text.isNotEmpty || - _noteController.text.isNotEmpty - ? _onSave - : null, - child: const Text("Hinzufügen"), - ), - OutlinedButton( - onPressed: () { - _noteController.clear(); - _noteSelectionController.clear(); - }, - child: const Text("Zurücksetzen"), - ), - ], - ), - ], - ), - ), - ), - ); - } -} diff --git a/lib/feature/delivery/detail/presentation/note/note_edit_dialog.dart b/lib/feature/delivery/detail/presentation/note/note_edit_dialog.dart deleted file mode 100644 index b9a758d..0000000 --- a/lib/feature/delivery/detail/presentation/note/note_edit_dialog.dart +++ /dev/null @@ -1,111 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:hl_lieferservice/feature/delivery/detail/bloc/note_bloc.dart'; -import 'package:hl_lieferservice/feature/delivery/detail/bloc/note_event.dart'; -import 'package:hl_lieferservice/model/delivery.dart'; - -class NoteEditDialog extends StatefulWidget { - final Note note; - - const NoteEditDialog({super.key, required this.note}); - - @override - State createState() => _NoteEditDialogState(); -} - -class _NoteEditDialogState extends State { - final _formKey = GlobalKey(); - late TextEditingController _editController; - late FocusNode _noteFieldFocusNode; - - @override - void initState() { - super.initState(); - - _noteFieldFocusNode = FocusNode(); - - _editController = TextEditingController(text: widget.note.content); - } - - void _onEdit(BuildContext context) { - context.read().add( - EditNote( - content: _editController.text, - noteId: widget.note.id.toString(), - ), - ); - - Navigator.of(context).pop(); - } - - @override - Widget build(BuildContext context) { - return Dialog( - child: SizedBox( - width: MediaQuery.of(context).size.width, - height: MediaQuery.of(context).size.height * 0.5, - child: Padding( - padding: const EdgeInsets.all(20), - child: ListView( - children: [ - Text( - "Notiz bearbeiten", - style: Theme.of(context).textTheme.headlineMedium, - ), - - Padding( - padding: const EdgeInsets.only(top: 15), - child: Form( - key: _formKey, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - TextFormField( - focusNode: _noteFieldFocusNode, - onTapOutside: (_) { - _noteFieldFocusNode.unfocus(); - }, - textInputAction: TextInputAction.done, - onFieldSubmitted: (_) { - _noteFieldFocusNode.unfocus(); - }, - decoration: InputDecoration(label: const Text("Notiz")), - controller: _editController, - minLines: 10, - maxLines: 12, - ), - - Padding( - padding: const EdgeInsets.only(top: 20), - child: Row( - children: [ - FilledButton( - onPressed: () { - _onEdit(context); - }, - child: const Text("Bearbeiten"), - ), - - Padding( - padding: const EdgeInsets.only(left: 10), - child: OutlinedButton( - onPressed: () { - Navigator.of(context).pop(); - }, - child: const Text("Abbrechen"), - ), - ), - ], - ), - ), - ], - ), - ), - ), - ], - ), - ), - ), - ); - } -} diff --git a/lib/feature/delivery/detail/presentation/note/note_fail_page.dart b/lib/feature/delivery/detail/presentation/note/note_fail_page.dart deleted file mode 100644 index ec300af..0000000 --- a/lib/feature/delivery/detail/presentation/note/note_fail_page.dart +++ /dev/null @@ -1,43 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:hl_lieferservice/feature/delivery/detail/bloc/note_bloc.dart'; -import 'package:hl_lieferservice/feature/delivery/detail/bloc/note_event.dart'; -import 'package:hl_lieferservice/model/delivery.dart'; - -class NoteLoadingFailPage extends StatelessWidget { - const NoteLoadingFailPage({super.key, required this.delivery}); - - final Delivery delivery; - - void _onRetry(BuildContext context) { - context.read().add(LoadNote(delivery: delivery)); - } - - @override - Widget build(BuildContext context) { - return Center( - child: Padding( - padding: const EdgeInsets.all(50), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.error_outline, size: 72, color: Theme.of(context).colorScheme.error,), - Padding( - padding: const EdgeInsets.only(top: 30), - child: Text( - "Leider ist es beim Laden der Notizen zu einem Fehler gekommen.", - ), - ), - Padding( - padding: const EdgeInsets.only(top: 30), - child: FilledButton( - onPressed: () => _onRetry(context), - child: Text("Erneut versuchen"), - ), - ), - ], - ), - ), - ); - } -} diff --git a/lib/feature/delivery/detail/presentation/note/note_image_overview.dart b/lib/feature/delivery/detail/presentation/note/note_image_overview.dart deleted file mode 100644 index 21f7577..0000000 --- a/lib/feature/delivery/detail/presentation/note/note_image_overview.dart +++ /dev/null @@ -1,108 +0,0 @@ -import 'dart:typed_data'; - -import 'package:carousel_slider/carousel_slider.dart'; -import 'package:flutter/material.dart'; -import 'package:collection/collection.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:hl_lieferservice/feature/delivery/detail/bloc/note_bloc.dart'; -import 'package:hl_lieferservice/feature/delivery/detail/bloc/note_event.dart'; -import 'package:hl_lieferservice/model/delivery.dart'; - -class NoteImageOverview extends StatefulWidget { - final List images; - final String deliveryId; - - const NoteImageOverview({ - super.key, - required this.images, - required this.deliveryId, - }); - - @override - State createState() => _NoteImageOverviewState(); -} - -class _NoteImageOverviewState extends State { - int? _imageDeleting; - - void _onRemoveImage(int index) { - ImageNote note = widget.images[index]; - - context.read().add( - RemoveImageNote(objectId: note.objectId, deliveryId: widget.deliveryId), - ); - } - - Widget _buildImageCarousel() { - return CarouselSlider( - options: CarouselOptions( - height: 300.0, - aspectRatio: 2.0, - enableInfiniteScroll: false, - ), - items: - widget.images.mapIndexed((index, data) { - Uint8List bytes = data.data!; - - return Builder( - builder: (BuildContext context) { - return Stack( - children: [ - Padding( - padding: const EdgeInsets.all(15.0), - child: Image.memory( - bytes, - fit: BoxFit.fill, - width: 1920.0, - height: 1090.0, - ), - ), - _imageDeleting == index - ? Stack( - children: [ - Padding( - padding: const EdgeInsets.all(15.0), - child: Container( - color: Colors.black.withValues(alpha: 0.5), - ), - ), - Center( - child: CircularProgressIndicator( - backgroundColor: - Theme.of(context).colorScheme.onSecondary, - ), - ), - ], - ) - : Container(), - Positioned( - right: 0.0, - top: 0.0, - child: CircleAvatar( - radius: 20, - child: IconButton.filled( - onPressed: - !(_imageDeleting == index) - ? () { - _onRemoveImage(index); - } - : null, - icon: const Icon(Icons.delete, color: Colors.white), - ), - ), - ), - ], - ); - }, - ); - }).toList(), - ); - } - - @override - Widget build(BuildContext context) { - return widget.images.isEmpty - ? const Center(child: Text("Noch keine Bilder hochgeladen")) - : _buildImageCarousel(); - } -} diff --git a/lib/feature/delivery/detail/presentation/note/note_list.dart b/lib/feature/delivery/detail/presentation/note/note_list.dart deleted file mode 100644 index 4e806ee..0000000 --- a/lib/feature/delivery/detail/presentation/note/note_list.dart +++ /dev/null @@ -1,30 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:hl_lieferservice/feature/delivery/detail/model/note.dart'; -import 'package:hl_lieferservice/feature/delivery/detail/presentation/note/note_list_item.dart'; - -class NoteList extends StatelessWidget { - final List notes; - final String deliveryId; - - const NoteList({super.key, required this.notes, required this.deliveryId}); - - @override - Widget build(BuildContext context) { - if (notes.isEmpty) { - return const Center(child: Text("keine Notizen vorhanden")); - } - - return ListView.separated( - shrinkWrap: true, - physics: NeverScrollableScrollPhysics(), - itemBuilder: - (context, index) => NoteListItem( - note: notes[index], - deliveryId: deliveryId, - index: index, - ), - separatorBuilder: (context, index) => const Divider(height: 0), - itemCount: notes.length, - ); - } -} diff --git a/lib/feature/delivery/detail/presentation/note/note_list_item.dart b/lib/feature/delivery/detail/presentation/note/note_list_item.dart deleted file mode 100644 index 455aac9..0000000 --- a/lib/feature/delivery/detail/presentation/note/note_list_item.dart +++ /dev/null @@ -1,105 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:hl_lieferservice/feature/delivery/detail/bloc/note_bloc.dart'; -import 'package:hl_lieferservice/feature/delivery/detail/bloc/note_event.dart'; -import 'package:hl_lieferservice/feature/delivery/detail/model/note.dart'; -import 'package:hl_lieferservice/feature/delivery/detail/presentation/note/note_edit_dialog.dart'; -import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.dart'; -import 'package:hl_lieferservice/feature/delivery/bloc/tour_state.dart'; - -enum NoteItemAction { noteEdit, noteDelete } - -class NoteListItem extends StatelessWidget { - final NoteInformation note; - final String deliveryId; - final int index; - - const NoteListItem({ - super.key, - required this.note, - required this.deliveryId, - required this.index, - }); - - void _onDelete(BuildContext context) { - context.read().add(RemoveNote(noteId: note.note.id.toString())); - } - - Widget? _subtitle(BuildContext context) { - String discountArticleId = - (context.read().state as TourLoaded) - .tour - .discountArticleNumber; - - if (note.article != null && - note.article?.articleNumber == discountArticleId) { - return const Text("Begründung der Gutschrift"); - } - - return note.article != null ? Text(note.article!.name) : null; - } - - void _onEdit(BuildContext context) { - showDialog( - context: context, - builder: - (_) => BlocProvider.value( - value: context.read(), - child: NoteEditDialog(note: note.note), - ), - ); - } - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.all(0), - child: ListTile( - title: Text(note.note.content), - subtitle: _subtitle(context), - tileColor: Theme.of(context).colorScheme.surfaceContainerLowest, - leading: CircleAvatar(child: Text("${index + 1}")), - trailing: PopupMenuButton( - onSelected: (NoteItemAction action) { - switch (action) { - case NoteItemAction.noteDelete: - _onDelete(context); - break; - case NoteItemAction.noteEdit: - _onEdit(context); - break; - } - }, - itemBuilder: (_) { - return [ - PopupMenuItem( - value: NoteItemAction.noteEdit, - child: Row( - children: [ - Icon(Icons.edit, color: Colors.blueAccent), - Padding( - padding: const EdgeInsets.only(left: 5), - child: const Text("Editieren"), - ), - ], - ), - ), - PopupMenuItem( - value: NoteItemAction.noteDelete, - child: Row( - children: [ - Icon(Icons.delete, color: Colors.redAccent), - Padding( - padding: const EdgeInsets.only(left: 5), - child: const Text("Löschen"), - ), - ], - ), - ), - ]; - }, - ), - ), - ); - } -} diff --git a/lib/feature/delivery/detail/presentation/note/note_overview.dart b/lib/feature/delivery/detail/presentation/note/note_overview.dart deleted file mode 100644 index acbbeb2..0000000 --- a/lib/feature/delivery/detail/presentation/note/note_overview.dart +++ /dev/null @@ -1,181 +0,0 @@ - -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:hl_lieferservice/feature/delivery/detail/bloc/note_bloc.dart'; -import 'package:hl_lieferservice/feature/delivery/detail/bloc/note_event.dart'; -import 'package:hl_lieferservice/feature/delivery/detail/model/note.dart'; -import 'package:hl_lieferservice/feature/delivery/detail/presentation/note/note_add_dialog.dart'; -import 'package:hl_lieferservice/feature/delivery/detail/presentation/note/note_image_overview.dart'; -import 'package:hl_lieferservice/feature/delivery/detail/presentation/note/note_list.dart'; -import 'package:hl_lieferservice/model/delivery.dart'; -import 'package:hl_lieferservice/widget/operations/bloc/operation_bloc.dart'; -import 'package:hl_lieferservice/widget/operations/bloc/operation_event.dart'; -import 'package:image_picker/image_picker.dart'; - -enum NoteAction { - addNote, - addImage -} - -class NoteOverview extends StatefulWidget { - final List notes; - final List templates; - final List images; - final String deliveryId; - - const NoteOverview({ - super.key, - required this.notes, - required this.deliveryId, - required this.templates, - required this.images, - }); - - @override - State createState() => _NoteOverviewState(); -} - -class _NoteOverviewState extends State { - final _imagePicker = ImagePicker(); - - Widget _notes() { - for (final note in widget.notes) { - debugPrint("Note: ${note.note.content}"); - debugPrint("NOTE Article: ${note.article?.name.toString()}"); - } - - return NoteList(notes: widget.notes, deliveryId: widget.deliveryId); - } - - Widget _images() { - return NoteImageOverview( - images: widget.images, - deliveryId: widget.deliveryId, - ); - } - - void _onAddNote(BuildContext context) { - showDialog( - context: context, - builder: (_) { - return BlocProvider.value(value: context.read(), child: NoteAddDialog( - delivery: widget.deliveryId, - templates: widget.templates, - )); - }, - ); - } - - void _onAddImage(BuildContext context) async { - XFile? file = await _imagePicker.pickImage(source: ImageSource.camera); - if (file == null) { - context.read().add( - FailOperation(message: "Fehler beim Aufnehmen des Bildes"), - ); - return; - } - - context.read().add( - AddImageNote(file: file, deliveryId: widget.deliveryId), - ); - } - - @override - Widget build(BuildContext context) { - return Stack( - children: [ - Padding( - padding: const EdgeInsets.all(10), - child: ListView( - children: [ - Padding( - padding: const EdgeInsets.only(bottom: 10), - child: Text( - "Notizen", - style: Theme.of(context).textTheme.headlineMedium, - ), - ), - _notes(), - - Padding( - padding: const EdgeInsets.only(bottom: 10, top: 10), - child: Text( - "Bilder", - style: Theme.of(context).textTheme.headlineMedium, - ), - ), - - _images(), - ], - ), - ), - - Align( - alignment: Alignment.bottomRight, - child: Padding( - padding: const EdgeInsets.only(right: 25), - child: PopupMenuButton( - onSelected: (NoteAction action) { - switch (action) { - case NoteAction.addNote: - _onAddNote(context); - break; - case NoteAction.addImage: - _onAddImage(context); - break; - } - }, - itemBuilder: (_) { - return [ - PopupMenuItem( - value: NoteAction.addNote, - child: Row( - children: [ - Icon( - Icons.note_add_rounded, - color: Theme.of(context).primaryColor, - ), - Padding( - padding: const EdgeInsets.only(left: 10), - child: const Text("Notiz hinzufügen"), - ), - ], - ), - ), - PopupMenuItem( - value: NoteAction.addImage, - child: Row( - children: [ - Icon( - Icons.image, - color: Theme.of(context).primaryColor, - ), - Padding( - padding: const EdgeInsets.only(left: 10), - child: const Text("Bild hochladen"), - ), - ], - ), - ), - ]; - }, - style: ButtonStyle( - backgroundColor: WidgetStatePropertyAll( - Theme.of(context).primaryColor, - ), - ), - child: CircleAvatar( - radius: 32, - backgroundColor: Theme.of(context).primaryColor, - child: Icon( - Icons.add, - color: Theme.of(context).colorScheme.onSecondary, - ), - ), - ), - ), - ), - ], - ); - } -} diff --git a/lib/feature/delivery/detail/presentation/steps/step.dart b/lib/feature/delivery/detail/presentation/steps/step.dart deleted file mode 100644 index eae03e0..0000000 --- a/lib/feature/delivery/detail/presentation/steps/step.dart +++ /dev/null @@ -1,32 +0,0 @@ -import 'package:flutter/cupertino.dart'; -import 'package:hl_lieferservice/feature/delivery/detail/presentation/steps/step_article_management.dart'; -import 'package:hl_lieferservice/feature/delivery/detail/presentation/steps/step_delivery_options.dart'; -import 'package:hl_lieferservice/feature/delivery/detail/presentation/steps/step_info.dart'; -import 'package:hl_lieferservice/feature/delivery/detail/presentation/steps/step_note.dart'; -import 'package:hl_lieferservice/feature/delivery/detail/presentation/steps/step_summary.dart'; -import 'package:hl_lieferservice/model/delivery.dart'; - -abstract class IStepFactory { - Widget? make(int step, Delivery delivery); -} - -class StepFactory extends IStepFactory { - @override - Widget? make(int step, Delivery delivery) { - switch(step) { - case 0: - return DeliveryStepInfo(delivery: delivery); - case 1: - return DeliveryStepNote(delivery: delivery); - case 2: - return DeliveryStepArticleManagement(delivery: delivery); - case 3: - return DeliveryStepOptions(delivery: delivery); - case 4: - return DeliveryStepSummary(delivery: delivery); - } - - return null; - } - -} \ No newline at end of file diff --git a/lib/feature/delivery/detail/presentation/steps/step_article_management.dart b/lib/feature/delivery/detail/presentation/steps/step_article_management.dart deleted file mode 100644 index 4d4711b..0000000 --- a/lib/feature/delivery/detail/presentation/steps/step_article_management.dart +++ /dev/null @@ -1,74 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:hl_lieferservice/feature/delivery/detail/presentation/article/article_list.dart'; -import 'package:hl_lieferservice/feature/delivery/detail/presentation/delivery_discount.dart'; -import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.dart'; -import 'package:hl_lieferservice/feature/delivery/bloc/tour_state.dart'; -import 'package:hl_lieferservice/model/delivery.dart'; - -class DeliveryStepArticleManagement extends StatefulWidget { - final Delivery delivery; - - const DeliveryStepArticleManagement({required this.delivery, super.key}); - - @override - State createState() => _DeliveryStepInfo(); -} - -class _DeliveryStepInfo extends State { - Widget _articleOverview() { - TourLoaded tour = context.read().state as TourLoaded; - - return ArticleList( - articles: - widget.delivery.articles - .where( - (article) => - article.articleNumber != tour.tour.discountArticleNumber, - ) - .toList(), - deliveryId: widget.delivery.id, - ); - } - - Widget _discountView() { - return DeliveryDiscount( - disabled: false, - discount: widget.delivery.discount, - deliveryId: widget.delivery.id, - ); - } - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.all(10), - child: Container( - width: double.infinity, - alignment: Alignment.centerLeft, - child: ListView( - children: [ - Padding( - padding: const EdgeInsets.only(bottom: 10), - child: Text( - "Artikel", - style: Theme.of(context).textTheme.headlineMedium, - ), - ), - _articleOverview(), - - Padding( - padding: const EdgeInsets.only(top: 20, bottom: 10), - child: Text( - "Gutschriften", - style: Theme.of(context).textTheme.headlineMedium, - ), - ), - - _discountView(), - ], - ), - ), - ); - } -} diff --git a/lib/feature/delivery/detail/presentation/steps/step_articles.dart b/lib/feature/delivery/detail/presentation/steps/step_articles.dart new file mode 100644 index 0000000..843c68a --- /dev/null +++ b/lib/feature/delivery/detail/presentation/steps/step_articles.dart @@ -0,0 +1,395 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'package:hl_lieferservice/domain/entity/delivery.dart'; +import 'package:hl_lieferservice/domain/entity/delivery_item.dart'; +import 'package:hl_lieferservice/domain/entity/scan_progress.dart'; +import 'package:hl_lieferservice/domain/entity/tour_details.dart'; +import 'package:hl_lieferservice/feature/car_selection/bloc/bloc.dart'; +import 'package:hl_lieferservice/feature/car_selection/bloc/state.dart'; +import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.dart'; +import 'package:hl_lieferservice/feature/delivery/bloc/tour_event.dart'; +import 'package:hl_lieferservice/feature/delivery/detail/presentation/widget/discount_editor.dart'; +import 'package:hl_lieferservice/feature/loading/widget/reason_catalog.dart'; +import 'package:hl_lieferservice/feature/loading/widget/reason_picker_sheet.dart'; + +/// Step 3 — Artikel & Gutschriften. +/// +/// * **Mengen-Gutschrift** (Belegzeile ganz ODER teilweise entfernen) ist +/// voll funktional über `RemoveItem`/`UnremoveItem` (mit `quantity`) → +/// Backend Audit-Action `remove`/`unremove`. Die `creditedQuantity` des +/// Items ist die Wahrheit (vom Server), kein lokaler Draft mehr. +/// Regeln (serverseitig erzwungen): scannbare Position muss `done` sein, +/// Lieferung muss `active` sein. Die UI spiegelt das als Gate + Hinweis. +/// * **Betrags-Gutschrift** (≤ 150 € in 10er-Schritten + Grund) ist +/// backend-gestützt über `SetDeliveryCredit`/`RemoveDeliveryCredit` am +/// `TourBloc` → `POST /deliveries/{id}/credit`. Wahrheit ist der Server +/// (`TourDetails.creditOf`), kein lokaler Draft mehr. +class StepArticles extends StatelessWidget { + const StepArticles({ + super.key, + required this.delivery, + required this.details, + }); + + final Delivery delivery; + final TourDetails details; + + @override + Widget build(BuildContext context) { + // Innerhalb einer Belegzeile: Oberartikel vor seinen Komponenten, damit + // Komponenten direkt darunter eingerückt erscheinen. + final items = List.of(delivery.items) + ..sort((a, b) { + final byLine = a.belegzeilenNr.compareTo(b.belegzeilenNr); + if (byLine != 0) return byLine; + final byParent = + (a.isComponent ? 1 : 0).compareTo(b.isComponent ? 1 : 0); + if (byParent != 0) return byParent; + return (a.komponentenArtikelNr ?? '') + .compareTo(b.komponentenArtikelNr ?? ''); + }); + return ListView( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 24), + children: [ + _SectionHeader(text: 'Artikel'), + const SizedBox(height: 8), + if (delivery.state != DeliveryState.active) ...[ + const _LockedHint( + text: 'Nur bei aktiver Lieferung änderbar.', + ), + const SizedBox(height: 8), + ], + if (items.isEmpty) + const _EmptyHint(text: 'Keine Artikel hinterlegt.') + else + Card( + margin: EdgeInsets.zero, + child: Column( + children: [ + for (int i = 0; i < items.length; i++) ...[ + _ArticleManagementRow( + item: items[i], + details: details, + deliveryId: delivery.id, + deliveryActive: delivery.state == DeliveryState.active, + ), + if (i < items.length - 1) + const Divider(height: 1, indent: 16, endIndent: 16), + ], + ], + ), + ), + const SizedBox(height: 24), + _SectionHeader(text: 'Gutschriften'), + const SizedBox(height: 8), + Card( + margin: EdgeInsets.zero, + child: Padding( + padding: const EdgeInsets.all(16), + child: DiscountEditor( + deliveryId: delivery.id, + active: delivery.state == DeliveryState.active, + ), + ), + ), + ], + ); + } +} + +class _SectionHeader extends StatelessWidget { + const _SectionHeader({required this.text}); + final String text; + + @override + Widget build(BuildContext context) { + return Text( + text, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ); + } +} + +class _EmptyHint extends StatelessWidget { + const _EmptyHint({required this.text}); + final String text; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Card( + margin: EdgeInsets.zero, + child: Padding( + padding: const EdgeInsets.all(16), + child: Text( + text, + style: TextStyle( + color: theme.colorScheme.onSurfaceVariant, + fontStyle: FontStyle.italic, + ), + ), + ), + ); + } +} + +/// Eine Zeile in der Artikel-Verwaltung. Quelle der Wahrheit ist +/// `item.scanProgress.creditedQuantity` (vom Backend) — kein lokaler Draft. +/// Zeigt: +/// - verbleibende Liefermenge (Soll − Gutschrift) +/// - Gutschrift-Button → Mengen-Dialog (1…Restmenge + Grund), gesperrt für +/// scannbare, noch nicht gescannte Positionen oder inaktive Lieferung +/// - Wiederherstellen-Button, sobald etwas gutgeschrieben ist +class _ArticleManagementRow extends StatelessWidget { + const _ArticleManagementRow({ + required this.item, + required this.details, + required this.deliveryId, + required this.deliveryActive, + }); + + final DeliveryItem item; + final TourDetails details; + final String deliveryId; + final bool deliveryActive; + + Future _openCreditDialog( + BuildContext context, { + required int remaining, + }) async { + final tourBloc = context.read(); + final actorCarId = _actorCarId(context); + // Identische Auswahl wie im Beladen-/Scan-Screen: Grund-Picker + // (Presets + „Anderer Grund") + Mengen-Stepper (Teilmengen-Gutschrift). + final result = await showReasonPickerSheet( + context: context, + title: 'Grund für das Entfernen', + presets: ReasonCatalog.itemRemove, + confirmLabel: 'Entfernen', + maxQuantity: remaining, + ); + if (result == null) return; + + // Eine Aktion für ganz UND teilweise: das Backend kippt die Zeile auf + // `removed`, sobald die volle Menge gutgeschrieben ist. + tourBloc.add(RemoveItem( + deliveryItemId: item.id, + reason: result.reason, + actorCarId: actorCarId, + quantity: result.quantity, + // Gutschrift-Grund zusätzlich als Lieferungs-Notiz festhalten. + saveReasonAsNote: true, + )); + } + + void _restoreAll(BuildContext context) { + // quantity: null → gesamte Gutschrift zurücknehmen. + context.read().add(UnremoveItem( + deliveryItemId: item.id, + actorCarId: _actorCarId(context), + )); + } + + /// Holt die aktuelle Auto-ID aus dem CarSelectBloc — gleiche Konvention + /// wie der Loading-Flow: das aktiv gewählte Fahrzeug ist der „Akteur" + /// des Audit-Events. Falls (defensiv) noch keine Auswahl getroffen ist, + /// fallback auf einen Null-UUID-String, damit der Backend-Call nicht + /// validation-failt. + String _actorCarId(BuildContext context) { + final state = context.read().state; + if (state is CarSelectComplete) return state.selectedCar.id; + return '00000000-0000-0000-0000-000000000000'; + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final article = details.articleOf(item.articleId); + final warehouse = details.warehouseOf(item.warehouseId); + + final required = item.requiredQuantity; + final credited = item.scanProgress.creditedQuantity; + final remaining = required - credited; + final fullyRemoved = item.isRemoved; // Status == removed (voll gutgeschr.) + final partiallyCredited = credited > 0 && !fullyRemoved; + + // Gate: scannbare Position muss `done` sein, sonst keine Gutschrift. + final scannable = article?.scannable ?? false; + final isDone = item.scanProgress.status == ScanStatus.done; + final blockedByScan = scannable && !isDone && !fullyRemoved; + final canCredit = deliveryActive && !blockedByScan && remaining > 0; + + final Color avatarColor; + final String avatarText; + if (fullyRemoved) { + avatarColor = Colors.red.shade400; + avatarText = '0×'; + } else if (partiallyCredited) { + avatarColor = Colors.amber.shade700; + avatarText = '$remaining×'; + } else { + avatarColor = theme.colorScheme.primary; + avatarText = '$required×'; + } + + return ListTile( + // Komponenten um eine Stufe eingerückt (gehören zum Oberartikel darüber). + contentPadding: + EdgeInsets.only(left: item.isComponent ? 40 : 16, right: 16), + leading: CircleAvatar( + backgroundColor: avatarColor, + foregroundColor: theme.colorScheme.onPrimary, + child: Text( + avatarText, + style: const TextStyle(fontSize: 11, fontWeight: FontWeight.bold), + ), + ), + title: Text( + '${item.isComponent ? '↳ ' : ''}${article?.name ?? '⟨Unbekannter Artikel⟩'}', + style: TextStyle( + fontWeight: FontWeight.w600, + decoration: fullyRemoved ? TextDecoration.lineThrough : null, + color: fullyRemoved ? theme.colorScheme.onSurfaceVariant : null, + ), + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + [ + article?.articleNumber ?? item.articleId, + if (warehouse != null) warehouse.name, + if (article?.scannable == false) 'Dienstleistung', + ].join(' · '), + style: TextStyle( + fontSize: 12, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + if (fullyRemoved) + _StatusLine( + text: 'Komplett gutgeschrieben' + '${item.scanProgress.heldReason != null ? ' – ${item.scanProgress.heldReason}' : ''}', + color: Colors.red.shade400, + ) + else if (partiallyCredited) + _StatusLine( + text: '$credited von $required gutgeschrieben', + color: Colors.amber.shade800, + ), + if (blockedByScan) + _StatusLine( + text: 'Erst scannen/verladen — dann Gutschrift möglich', + color: theme.colorScheme.onSurfaceVariant, + ), + ], + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (credited > 0) + IconButton( + // Wiederherstellen nur bei aktiver Lieferung — bei + // abgeschlossener/abgebrochener Lieferung gesperrt (greift auch + // backend-seitig, hier zusätzlich in der UI). + tooltip: deliveryActive + ? 'Gutschrift zurücknehmen' + : 'Nur bei aktiver Lieferung', + icon: Icon( + Icons.restore, + color: deliveryActive + ? theme.colorScheme.primary + : theme.colorScheme.onSurfaceVariant, + ), + onPressed: deliveryActive ? () => _restoreAll(context) : null, + ), + if (!fullyRemoved) + IconButton.outlined( + tooltip: blockedByScan + ? 'Erst scannen/verladen' + : (!deliveryActive + ? 'Nur bei aktiver Lieferung' + : 'Gutschrift / entfernen'), + style: ButtonStyle( + backgroundColor: WidgetStatePropertyAll( + canCredit + ? Colors.redAccent + : theme.colorScheme.surfaceContainerHighest, + ), + ), + onPressed: canCredit + ? () => _openCreditDialog(context, remaining: remaining) + : null, + icon: Icon( + Icons.delete, + color: canCredit + ? theme.colorScheme.onPrimary + : theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ); + } +} + +/// Kleine farbige Statuszeile unter dem Artikelnamen. +class _StatusLine extends StatelessWidget { + const _StatusLine({required this.text, required this.color}); + final String text; + final Color color; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(top: 2), + child: Text( + text, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: color, + ), + ), + ); + } +} + +/// Lock-Hinweis (analog DiscountEditor): zeigt mit Schloss-Symbol an, dass die +/// Artikel-Aktionen (Entfernen / Wiederherstellen) nur bei aktiver Lieferung +/// möglich sind. +class _LockedHint extends StatelessWidget { + const _LockedHint({required this.text}); + final String text; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Icon(Icons.lock_outline, + size: 16, color: theme.colorScheme.onSurfaceVariant), + const SizedBox(width: 8), + Expanded( + child: Text( + text, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/feature/delivery/detail/presentation/steps/step_delivery_options.dart b/lib/feature/delivery/detail/presentation/steps/step_delivery_options.dart deleted file mode 100644 index 090fafd..0000000 --- a/lib/feature/delivery/detail/presentation/steps/step_delivery_options.dart +++ /dev/null @@ -1,25 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:hl_lieferservice/feature/delivery/detail/presentation/delivery_options.dart'; -import 'package:hl_lieferservice/model/delivery.dart' as model; - -class DeliveryStepOptions extends StatefulWidget { - final model.Delivery delivery; - - const DeliveryStepOptions({required this.delivery, super.key}); - - @override - State createState() => _DeliveryStepInfo(); -} - -class _DeliveryStepInfo extends State { - @override - Widget build(BuildContext context) { - debugPrint( - "${widget.delivery.options.map((option) => "${option.display}, ${option.value}")}", - ); - return DeliveryOptionsView( - options: widget.delivery.options, - deliveryId: widget.delivery.id, - ); - } -} diff --git a/lib/feature/delivery/detail/presentation/steps/step_info.dart b/lib/feature/delivery/detail/presentation/steps/step_info.dart index bbbc3a2..d396ddb 100644 --- a/lib/feature/delivery/detail/presentation/steps/step_info.dart +++ b/lib/feature/delivery/detail/presentation/steps/step_info.dart @@ -1,337 +1,484 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:hl_lieferservice/feature/delivery/bloc/tour_event.dart'; -import 'package:hl_lieferservice/model/article.dart'; -import 'package:hl_lieferservice/model/delivery.dart'; import 'package:url_launcher/url_launcher.dart'; -import '../../../bloc/tour_bloc.dart'; -import '../../../bloc/tour_state.dart'; +import 'package:hl_lieferservice/domain/entity/contact_source.dart'; +import 'package:hl_lieferservice/domain/entity/customer.dart'; +import 'package:hl_lieferservice/domain/entity/delivery.dart'; +import 'package:hl_lieferservice/domain/entity/delivery_item.dart'; +import 'package:hl_lieferservice/domain/entity/tour_details.dart'; +import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.dart'; +import 'package:hl_lieferservice/feature/delivery/bloc/tour_event.dart'; -enum _StatusAction { hold, cancel, reactivate } +/// Step 1 — Informationen zur Lieferung. +/// +/// Schnellaktionen oben (Anrufen, Maps, Status-Menü), darunter +/// Sondervereinbarungen + Wunschzeit, Kunden-Block (Name + Adresse + +/// Kontakte), zu liefernde Artikel inkl. nicht-scanbarer +/// Dienstleistungen (z. B. „Aufbauservice", „Anlieferung"). Reine +/// Anzeige — Schreib-Aktionen wandern in die anderen Steps. +class StepInfo extends StatelessWidget { + const StepInfo({super.key, required this.delivery, required this.details}); -class DeliveryStepInfo extends StatefulWidget { final Delivery delivery; - - const DeliveryStepInfo({required this.delivery, super.key}); + final TourDetails details; @override - State createState() => _DeliveryStepInfo(); -} + Widget build(BuildContext context) { + final customer = details.customerOf(delivery); + final contacts = details.contactsOf(delivery).toList(); + // Vereint Quellen mit identischem Namen + identischer Channel-Liste, + // damit derselbe Datensatz nicht zweimal (z. B. „Belegadresse" UND + // „Kundenstamm") aufpoppt. + final mergedSources = details.mergedContactSourcesOf(delivery); -class _DeliveryStepInfo extends State { - void _launchMapsUrl(String mapsApp) async { - final address = widget.delivery.customer.address.toString(); - final encodedAddress = Uri.encodeComponent(address); - Uri url; - - switch (mapsApp) { - case 'google': - url = Uri.parse( - 'https://www.google.com/maps/search/?api=1&query=$encodedAddress', - ); - break; - case 'apple': - url = Uri.parse('http://maps.apple.com/?daddr=$encodedAddress'); - break; - default: - return; - } - - await launchUrl(url, mode: LaunchMode.externalApplication); - } - - Widget _statusOverflow() { - final state = widget.delivery.state; - final List> entries; - - if (state == DeliveryState.ongoing) { - entries = const [ - PopupMenuItem( - value: _StatusAction.hold, - child: Row( - children: [ - Icon(Icons.change_circle, color: Colors.orangeAccent), - SizedBox(width: 12), - Text("Zurückstellen"), - ], - ), + return ListView( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 16), + children: [ + _SectionHeader(text: 'Beleg'), + const SizedBox(height: 8), + _BelegCard(delivery: delivery), + const SizedBox(height: 24), + _SectionHeader(text: 'Schnellaktionen'), + const SizedBox(height: 8), + _QuickActions( + delivery: delivery, + customer: customer, + mergedSources: mergedSources, ), - PopupMenuItem( - value: _StatusAction.cancel, - child: Row( - children: [ - Icon(Icons.cancel, color: Colors.red), - SizedBox(width: 12), - Text("Abbrechen"), - ], - ), - ), - ]; - } else { - entries = const [ - PopupMenuItem( - value: _StatusAction.reactivate, - child: Row( - children: [ - Icon(Icons.published_with_changes, color: Colors.blueAccent), - SizedBox(width: 12), - Text("Reaktivieren"), - ], - ), - ), - ]; - } - - return PopupMenuButton<_StatusAction>( - icon: const Icon(Icons.more_vert), - tooltip: "Status ändern", - itemBuilder: (context) => entries, - onSelected: (action) { - switch (action) { - case _StatusAction.hold: - context.read().add( - HoldDeliveryEvent(deliveryId: widget.delivery.id), - ); - Navigator.of(context).pop(); - break; - case _StatusAction.cancel: - context.read().add( - CancelDeliveryEvent(deliveryId: widget.delivery.id), - ); - Navigator.of(context).pop(); - break; - case _StatusAction.reactivate: - context.read().add( - ReactivateDeliveryEvent(deliveryId: widget.delivery.id), - ); - break; - } - }, + const SizedBox(height: 24), + _SectionHeader(text: 'Sondervereinbarungen'), + const SizedBox(height: 8), + _AgreementsCard(delivery: delivery), + const SizedBox(height: 24), + _SectionHeader(text: 'Kundeninformationen'), + const SizedBox(height: 8), + _CustomerCard(customer: customer, contacts: contacts), + if (mergedSources.isNotEmpty) ...[ + const SizedBox(height: 24), + _SectionHeader(text: 'Alle Kontaktinfos'), + const SizedBox(height: 8), + _AllContactsCard(sources: mergedSources), + ], + const SizedBox(height: 24), + _SectionHeader(text: 'Zu liefernde Artikel'), + const SizedBox(height: 8), + _ArticleList(delivery: delivery, details: details), + ], ); } +} - Widget _fastActions() { - return SizedBox( - width: double.infinity, - child: Card( - color: Theme.of(context).colorScheme.onSecondary, - child: Padding( - padding: const EdgeInsets.all(10), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Builder( - builder: (context) { - final phone = widget.delivery.contactPerson?.phoneNumber; - final bool hasPhone = phone != null && phone.isNotEmpty; - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton.filled( - onPressed: hasPhone - ? () async { - await launchUrl( - Uri(scheme: "tel", path: phone), - ); - } - : null, - icon: const Icon(Icons.phone), - ), - const Text("Anrufen"), - ], - ); - }, - ), - ), - Expanded( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton.filled( - onPressed: () => _launchMapsUrl("google"), - icon: const Icon(Icons.map_outlined), - ), - const Text("Google Maps"), - ], - ), - ), - _statusOverflow(), - ], +class _SectionHeader extends StatelessWidget { + const _SectionHeader({required this.text}); + final String text; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Text( + text, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ); + } +} + +// ─── Beleg ────────────────────────────────────────────────────────────── + +class _BelegCard extends StatelessWidget { + const _BelegCard({required this.delivery}); + + final Delivery delivery; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Card( + margin: EdgeInsets.zero, + child: ListTile( + leading: Icon( + Icons.receipt_long_outlined, + color: theme.colorScheme.primary, + ), + title: const Text('Belegnummer'), + subtitle: Text( + delivery.erpBelegnummer, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, ), ), ), ); } +} - Widget _customerInformation() { - final phone = widget.delivery.contactPerson?.phoneNumber; - final String phoneText = (phone != null && phone.isNotEmpty) - ? phone - : "keine Nummer angegeben"; +// ─── Schnellaktionen ──────────────────────────────────────────────────── - final email = widget.delivery.customer.email; - final String emailText = (email != null && email.isNotEmpty) - ? email - : "keine E-Mail angegeben"; +enum _StatusAction { hold, cancel, resume } - return SizedBox( - width: double.infinity, - child: Card( - color: Theme.of(context).colorScheme.onSecondary, - child: Padding( - padding: const EdgeInsets.all(10), - child: Column( +class _QuickActions extends StatelessWidget { + const _QuickActions({ + required this.delivery, + required this.customer, + required this.mergedSources, + }); + + final Delivery delivery; + final Customer? customer; + final List mergedSources; + + /// Alle aus den Beleg-Kontaktquellen anrufbaren Nummern, in + /// Sheet-Anzeigeform aufbereitet: + /// + /// * Phone vor Mobile pro Quelle, Quelle-Reihenfolge wie vom Backend. + /// * Duplikate gleicher Nummer (z. B. dieselbe Mobilnummer in Beleg- + /// und Kunden­stammadresse) werden auf den ersten Eintrag gemerged, + /// die zweite Rolle ergänzt das Label. + /// * Whitespace wird zum Vergleich entfernt, damit „+49 89 123" und + /// „+498 9123" als dieselbe Nummer durchgehen, falls der Anwender + /// sie mal mit, mal ohne Leerzeichen erfasst hat. Angezeigt wird der + /// Originalwert der ersten Quelle. + List<_CallableNumber> _collectCallableNumbers() { + final out = <_CallableNumber>[]; + final byNormalized = {}; + for (final src in mergedSources) { + for (final ch in src.channels) { + if (ch.kind != ContactKind.phone && ch.kind != ContactKind.mobile) { + continue; + } + final normalized = ch.value.replaceAll(RegExp(r'\s+'), ''); + if (normalized.isEmpty) continue; + final existing = byNormalized[normalized]; + if (existing != null) { + out[existing] = out[existing].withAdditionalRole(src.rolesLabel); + continue; + } + byNormalized[normalized] = out.length; + out.add(_CallableNumber( + value: ch.value, + kind: ch.kind, + roleLabel: src.rolesLabel, + displayName: src.displayName, + )); + } + } + return out; + } + + Future _launchMaps(BuildContext context) async { + final address = customer != null + ? '${customer!.address.street} ${customer!.address.houseNumber}, ' + '${customer!.address.postalCode} ${customer!.address.city}' + : delivery.deliveryAddressSnapshot.oneLine; + final encoded = Uri.encodeComponent(address); + // Universelles `geo:?q=…`-Schema funktioniert auf Android + iOS. + final uri = Uri.parse( + 'https://www.google.com/maps/search/?api=1&query=$encoded', + ); + await launchUrl(uri, mode: LaunchMode.externalApplication); + } + + Future _call(BuildContext context, String phone) async { + final ok = await launchUrl(Uri(scheme: 'tel', path: phone)); + if (!ok && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Anruf konnte nicht gestartet werden: $phone')), + ); + } + } + + /// Einstiegspunkt für den „Anrufen"-Button. + /// * Genau eine Nummer ⇒ direkt anrufen. + /// * Mehrere Nummern ⇒ Bottom-Sheet, danach anrufen. + /// Aufgerufen wird die Methode nur, wenn `numbers.isNotEmpty` — + /// die Validierung sitzt im `build` und schaltet den Button sonst aus. + Future _onCallTapped( + BuildContext context, + List<_CallableNumber> numbers, + ) async { + if (numbers.length == 1) { + await _call(context, numbers.first.value); + return; + } + final picked = await showModalBottomSheet<_CallableNumber>( + context: context, + showDragHandle: true, + builder: (sheetContext) { + return SafeArea( + child: ListView( + shrinkWrap: true, children: [ - Row( - children: [ - Icon(Icons.person, color: Theme.of(context).primaryColor), - Padding( - padding: const EdgeInsets.only(left: 10), - child: Text( - widget.delivery.customer.name, - style: TextStyle(fontWeight: FontWeight.bold), - ), - ), - ], - ), - Padding( - padding: const EdgeInsets.only(top: 15), - child: Row( - children: [ - Icon( - Icons.other_houses, - color: Theme.of(context).primaryColor, - ), - Padding( - padding: const EdgeInsets.only(left: 10), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + padding: const EdgeInsets.fromLTRB(16, 4, 16, 12), + child: Text( + 'Nummer wählen', + style: Theme.of(sheetContext).textTheme.titleMedium, + ), + ), + for (final n in numbers) + ListTile( + leading: Icon( + n.kind == ContactKind.mobile + ? Icons.smartphone + : Icons.call, + color: Theme.of(sheetContext).colorScheme.primary, + ), + title: Text(n.value), + subtitle: Text( + n.displayName == null + ? n.roleLabel + : '${n.displayName} · ${n.roleLabel}', + ), + onTap: () => Navigator.of(sheetContext).pop(n), + ), + ], + ), + ); + }, + ); + if (picked == null || !context.mounted) return; + await _call(context, picked.value); + } + + Future _askReason(BuildContext context, String title) async { + final controller = TextEditingController(); + final result = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(title), + content: TextField( + controller: controller, + autofocus: true, + decoration: const InputDecoration( + labelText: 'Grund', + border: OutlineInputBorder(), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(null), + child: const Text('Abbrechen'), + ), + FilledButton( + onPressed: () { + final text = controller.text.trim(); + if (text.isEmpty) return; + Navigator.of(dialogContext).pop(text); + }, + child: const Text('Bestätigen'), + ), + ], + ), + ); + return result; + } + + Future _onStatusSelected( + BuildContext context, + _StatusAction action, + ) async { + final tourBloc = context.read(); + switch (action) { + case _StatusAction.hold: + final reason = await _askReason(context, 'Lieferung pausieren'); + if (reason == null) return; + tourBloc.add(HoldDelivery(deliveryId: delivery.id, reason: reason)); + case _StatusAction.cancel: + final reason = await _askReason(context, 'Lieferung abbrechen'); + if (reason == null) return; + tourBloc.add(CancelDelivery(deliveryId: delivery.id, reason: reason)); + case _StatusAction.resume: + tourBloc.add(ResumeDelivery(deliveryId: delivery.id)); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final numbers = _collectCallableNumbers(); + final hasPhone = numbers.isNotEmpty; + final isActive = delivery.state == DeliveryState.active; + final isHeld = delivery.state == DeliveryState.held; + + return Card( + margin: EdgeInsets.zero, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12), + child: Row( + children: [ + Expanded( + child: _QuickButton( + icon: Icons.phone, + label: 'Anrufen', + enabled: hasPhone, + onTap: hasPhone ? () => _onCallTapped(context, numbers) : null, + ), + ), + Expanded( + child: _QuickButton( + icon: Icons.map_outlined, + label: 'Maps', + enabled: true, + onTap: () => _launchMaps(context), + ), + ), + PopupMenuButton<_StatusAction>( + icon: Icon( + Icons.more_vert, + color: theme.colorScheme.primary, + ), + tooltip: 'Status ändern', + onSelected: (a) => _onStatusSelected(context, a), + itemBuilder: (context) { + if (isActive) { + return const [ + PopupMenuItem( + value: _StatusAction.hold, + child: Row( children: [ - Text(widget.delivery.customer.address.street), - Text( - "${widget.delivery.customer.address.postalCode} ${widget.delivery.customer.address.city}", - ), + Icon(Icons.pause_circle_outline, + color: Colors.orange), + SizedBox(width: 12), + Text('Pausieren'), ], ), ), - ], - ), - ), - - Padding( - padding: const EdgeInsets.only(top: 15), - child: Row( - children: [ - Icon(Icons.phone, color: Theme.of(context).primaryColor), - Padding( - padding: const EdgeInsets.only(left: 10), - child: Text(phoneText), - ), - ], - ), - ), - - Padding( - padding: const EdgeInsets.only(top: 15), - child: Row( - children: [ - Icon(Icons.mail, color: Theme.of(context).primaryColor), - Expanded( - child: Padding( - padding: const EdgeInsets.only(left: 10), - child: Text( - emailText, - overflow: TextOverflow.ellipsis, - ), + PopupMenuItem( + value: _StatusAction.cancel, + child: Row( + children: [ + Icon(Icons.cancel_outlined, color: Colors.red), + SizedBox(width: 12), + Text('Abbrechen'), + ], ), ), - ], - ), - ), - ], - ), + ]; + } + if (isHeld) { + return const [ + PopupMenuItem( + value: _StatusAction.resume, + child: Row( + children: [ + Icon(Icons.play_circle_outline, + color: Colors.blueAccent), + SizedBox(width: 12), + Text('Fortsetzen'), + ], + ), + ), + ]; + } + // canceled / completed: keine Statusänderung mehr. + return const [ + PopupMenuItem( + enabled: false, + child: Text('Keine Aktionen verfügbar'), + ), + ]; + }, + ), + ], ), ), ); } +} - Widget _articleList() { - TourLoaded tour = context.read().state as TourLoaded; - List
filteredArticles = - widget.delivery.articles - .where( - (article) => - article.articleNumber != tour.tour.discountArticleNumber, - ) - .toList(); +class _QuickButton extends StatelessWidget { + const _QuickButton({ + required this.icon, + required this.label, + required this.enabled, + required this.onTap, + }); - return ListView.separated( - shrinkWrap: true, - physics: NeverScrollableScrollPhysics(), - itemBuilder: (context, index) { - Article article = filteredArticles[index]; + final IconData icon; + final String label; + final bool enabled; + final VoidCallback? onTap; - return DecoratedBox( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.onSecondary, - ), - child: ListTile( - title: Text(article.name), - subtitle: Text("Artikelnr. ${article.articleNumber}"), - leading: Chip(label: Text("${article.amount.toString()}x")), - ), - ); - }, - separatorBuilder: (context, index) => const Divider(height: 0), - itemCount: filteredArticles.length, + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return InkWell( + onTap: enabled ? onTap : null, + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton.filled( + onPressed: enabled ? onTap : null, + icon: Icon(icon), + ), + Text( + label, + style: TextStyle( + fontSize: 12, + color: enabled + ? theme.colorScheme.onSurface + : theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), ); } +} - Widget _agreementsAndDesiredTime() { - String agreements = "keine Vereinbarungen getroffen!"; - if (widget.delivery.specialAgreements != null && - widget.delivery.specialAgreements != "") { - agreements = widget.delivery.specialAgreements!; - } +// ─── Sondervereinbarungen + Wunschzeit ────────────────────────────────── - final desiredTime = widget.delivery.desiredTime; - final bool hasDesiredTime = desiredTime != null && desiredTime.isNotEmpty; - final primary = Theme.of(context).primaryColor; +class _AgreementsCard extends StatelessWidget { + const _AgreementsCard({required this.delivery}); + final Delivery delivery; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final desiredTime = delivery.desiredTime; + final hasDesired = desiredTime != null && desiredTime.isNotEmpty; + final agreements = delivery.specialAgreements; + final hasAgreements = agreements != null && agreements.isNotEmpty; return Card( - color: Theme.of(context).colorScheme.onSecondary, + margin: EdgeInsets.zero, child: Padding( - padding: const EdgeInsets.all(10), + padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - if (hasDesiredTime) ...[ + if (hasDesired) ...[ Row( children: [ - Padding( - padding: const EdgeInsets.all(15), - child: Icon(Icons.schedule, color: primary, size: 28), + Icon( + Icons.schedule, + color: theme.colorScheme.primary, + size: 28, ), + const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - "Wunschtermin", + 'Wunschtermin', style: TextStyle( fontSize: 12, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: theme.colorScheme.onSurfaceVariant, ), ), Text( desiredTime, style: TextStyle( - fontSize: 22, + fontSize: 20, fontWeight: FontWeight.bold, - color: primary, + color: theme.colorScheme.primary, ), ), ], @@ -339,80 +486,525 @@ class _DeliveryStepInfo extends State { ), ], ), - const Divider(height: 24), + if (hasAgreements) const Divider(height: 24), ], - Row( - children: [ - Padding( - padding: const EdgeInsets.all(15), - child: Icon(Icons.warning, color: primary, size: 28), + if (hasAgreements) + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.warning_amber_rounded, + color: Colors.amber.shade800, + size: 28, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + agreements, + style: const TextStyle(fontSize: 14), + ), + ), + ], + ), + if (!hasDesired && !hasAgreements) + Text( + 'Keine Sondervereinbarungen.', + style: TextStyle( + color: theme.colorScheme.onSurfaceVariant, + fontStyle: FontStyle.italic, ), - Expanded(child: Text(agreements)), - ], - ), - ], - ), - ), - ); - } - - @override - Widget build(BuildContext context) { - return Container( - alignment: Alignment.centerLeft, - child: Padding( - padding: const EdgeInsets.all(10), - child: ListView( - children: [ - Text( - "Schnellaktionen", - style: Theme.of(context).textTheme.headlineSmall, - ), - - Padding( - padding: const EdgeInsets.only(top: 10), - child: _fastActions(), - ), - - Padding( - padding: const EdgeInsets.only(top: 20), - child: Text( - "Sondervereinbarungen", - style: Theme.of(context).textTheme.headlineSmall, ), - ), - Padding( - padding: const EdgeInsets.only(top: 10), - child: _agreementsAndDesiredTime(), - ), - - Padding( - padding: const EdgeInsets.only(top: 20), - child: Text( - "Kundeninformationen", - style: Theme.of(context).textTheme.headlineSmall, - ), - ), - Padding( - padding: const EdgeInsets.only(top: 10), - child: _customerInformation(), - ), - - Padding( - padding: const EdgeInsets.only(top: 20), - child: Text( - "Zu liefernde Artikel", - style: Theme.of(context).textTheme.headlineSmall, - ), - ), - - Padding( - padding: const EdgeInsets.only(top: 20), - child: _articleList(), - ), ], ), ), ); } } + +// ─── Kunde + Kontakte ─────────────────────────────────────────────────── + +class _CustomerCard extends StatelessWidget { + const _CustomerCard({required this.customer, required this.contacts}); + + final Customer? customer; + final List contacts; + + Future _call(BuildContext context, String phone) async { + final ok = await launchUrl(Uri(scheme: 'tel', path: phone)); + if (!ok && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Anruf konnte nicht gestartet werden: $phone')), + ); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final c = customer; + return Card( + margin: EdgeInsets.zero, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.person, color: theme.colorScheme.primary), + const SizedBox(width: 10), + Expanded( + child: Text( + c?.name ?? '⟨Unbekannter Kunde⟩', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + if (c != null) ...[ + const SizedBox(height: 12), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.home_outlined, + color: theme.colorScheme.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${c.address.street} ${c.address.houseNumber}', + style: const TextStyle(fontSize: 14), + ), + Text( + '${c.address.postalCode} ${c.address.city}', + style: const TextStyle(fontSize: 14), + ), + ], + ), + ), + ], + ), + ], + if (contacts.isNotEmpty) ...[ + const Divider(height: 24), + Text( + 'Ansprechpartner', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 8), + for (final contact in contacts) ...[ + _ContactRow(contact: contact, onCall: _call), + ], + ], + ], + ), + ), + ); + } +} + +class _ContactRow extends StatelessWidget { + const _ContactRow({required this.contact, required this.onCall}); + + final CustomerContact contact; + final Future Function(BuildContext, String) onCall; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final phone = contact.phone; + final hasPhone = phone != null && phone.isNotEmpty; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + children: [ + Icon( + Icons.person_outline, + size: 18, + color: theme.colorScheme.primary, + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + contact.name, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + if (hasPhone) + Text( + phone, + style: TextStyle( + fontSize: 12, + color: theme.colorScheme.onSurfaceVariant, + ), + ) + else if (contact.email != null) + Text( + contact.email!, + style: TextStyle( + fontSize: 12, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + if (hasPhone) + IconButton( + icon: Icon(Icons.call, color: theme.colorScheme.primary), + tooltip: 'Anrufen', + onPressed: () => onCall(context, phone), + ), + ], + ), + ); + } +} + +// ─── Artikelliste (inkl. nicht-scanbarer Dienstleistungen) ────────────── + +class _ArticleList extends StatelessWidget { + const _ArticleList({required this.delivery, required this.details}); + + final Delivery delivery; + final TourDetails details; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + // ALLE Items, auch nicht-scanbare Dienstleistungen — Step 1 ist die + // Übersicht „was geht heute raus". Entfernte werden durchgestrichen + // mit angezeigt (Fahrer soll wissen, dass da was war). + // Sortierung: nach Belegzeile, und innerhalb einer Belegzeile der + // Oberartikel VOR seinen Komponenten — damit Komponenten direkt unter + // ihrem Oberartikel (eingerückt) erscheinen. + final items = List.of(delivery.items) + ..sort((a, b) { + final byLine = a.belegzeilenNr.compareTo(b.belegzeilenNr); + if (byLine != 0) return byLine; + final byParent = + (a.isComponent ? 1 : 0).compareTo(b.isComponent ? 1 : 0); + if (byParent != 0) return byParent; + return (a.komponentenArtikelNr ?? '') + .compareTo(b.komponentenArtikelNr ?? ''); + }); + + if (items.isEmpty) { + return Card( + margin: EdgeInsets.zero, + child: Padding( + padding: const EdgeInsets.all(16), + child: Text( + 'Keine Artikel hinterlegt.', + style: TextStyle( + fontStyle: FontStyle.italic, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ); + } + + return Card( + margin: EdgeInsets.zero, + child: Column( + children: [ + for (int i = 0; i < items.length; i++) ...[ + _ArticleRow(item: items[i], details: details), + if (i < items.length - 1) + const Divider(height: 1, indent: 16, endIndent: 16), + ], + ], + ), + ); + } +} + +class _ArticleRow extends StatelessWidget { + const _ArticleRow({required this.item, required this.details}); + + final DeliveryItem item; + final TourDetails details; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final article = details.articleOf(item.articleId); + final warehouse = details.warehouseOf(item.warehouseId); + final isScannable = article?.scannable ?? false; + final removed = item.isRemoved; + + return ListTile( + // Komponenten um eine Stufe eingerückt (gehören zum Oberartikel darüber). + contentPadding: + EdgeInsets.only(left: item.isComponent ? 40 : 16, right: 16), + leading: CircleAvatar( + backgroundColor: removed + ? theme.colorScheme.surfaceContainerHighest + : theme.colorScheme.primary, + foregroundColor: removed + ? theme.colorScheme.onSurfaceVariant + : theme.colorScheme.onPrimary, + child: Text( + '${item.requiredQuantity}×', + style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold), + ), + ), + title: Text( + '${item.isComponent ? '↳ ' : ''}${article?.name ?? '⟨Unbekannter Artikel⟩'}', + style: TextStyle( + fontWeight: FontWeight.w600, + decoration: removed ? TextDecoration.lineThrough : null, + color: removed ? theme.colorScheme.onSurfaceVariant : null, + ), + ), + subtitle: Text( + [ + article?.articleNumber ?? item.articleId, + if (warehouse != null) warehouse.name, + if (!isScannable) 'Dienstleistung', + if (item.unitPrice > 0) + '${item.unitPrice.toStringAsFixed(2)} € / Stück', + ].join(' · '), + style: TextStyle( + fontSize: 12, + color: theme.colorScheme.onSurfaceVariant, + decoration: removed ? TextDecoration.lineThrough : null, + ), + ), + trailing: isScannable + ? Text( + '${item.scanProgress.scannedQuantity} / ${item.requiredQuantity}', + style: const TextStyle( + fontWeight: FontWeight.bold, + ), + ) + : null, + ); + } +} + +// ─── Alle Kontaktinfos aus dem ERP ────────────────────────────────────── +// +// Die obige `_CustomerCard` zeigt nur die klassischen Ansprechpartner +// (Stammdaten-Liste pro Kunde). Diese Sektion zeigt die *belegspezifischen* +// Adress-Quellen aus dem ERP: Belegadresse / Lieferadresse / Rechnungs- +// adresse / Ansprechpartner / Kundenstamm — jede mit ihrem eigenen +// Namensblock und allen hinterlegten Telefon-/Mobil-/E-Mail-/Web-Kanälen. +// Sortiert kommen sie vom Backend; das UI bildet sie 1:1 ab. + +class _AllContactsCard extends StatelessWidget { + const _AllContactsCard({required this.sources}); + + final List sources; + + Future _launch(BuildContext context, Uri uri, String label) async { + final ok = await launchUrl(uri); + if (!ok && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('$label konnte nicht geöffnet werden')), + ); + } + } + + @override + Widget build(BuildContext context) { + return Card( + margin: EdgeInsets.zero, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (var i = 0; i < sources.length; i++) ...[ + if (i > 0) const Divider(height: 1), + _ContactSourceTile( + source: sources[i], + onLaunch: _launch, + ), + ], + ], + ), + ), + ); + } +} + +class _ContactSourceTile extends StatelessWidget { + const _ContactSourceTile({ + required this.source, + required this.onLaunch, + }); + + final MergedContactSource source; + final Future Function(BuildContext, Uri, String) onLaunch; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final channels = source.channels; + return Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Rollen-Label: alle zusammengeführten Rollen mit `·` getrennt + // (z. B. „Belegadresse · Kundenstamm"). + Text( + source.rolesLabel, + style: theme.textTheme.labelLarge?.copyWith( + color: theme.colorScheme.primary, + fontWeight: FontWeight.w700, + ), + ), + if (source.displayName != null) ...[ + const SizedBox(height: 2), + Text( + source.displayName!, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ], + if (source.subtitle != null) + Text( + source.subtitle!, + style: TextStyle( + fontSize: 12, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + if (channels.isEmpty) ...[ + const SizedBox(height: 6), + Text( + 'Keine Telefonnummer oder E-Mail hinterlegt.', + style: TextStyle( + fontSize: 12, + color: theme.colorScheme.onSurfaceVariant, + fontStyle: FontStyle.italic, + ), + ), + ] else ...[ + const SizedBox(height: 8), + for (final ch in channels) + _ChannelRow(channel: ch, onLaunch: onLaunch), + ], + ], + ), + ); + } +} + +class _ChannelRow extends StatelessWidget { + const _ChannelRow({required this.channel, required this.onLaunch}); + + final ContactChannel channel; + final Future Function(BuildContext, Uri, String) onLaunch; + + // Symbol + Aktion ergeben sich aus dem `kind` — drei Tap-Targets: + // phone/mobile → tel: | email → mailto: | web → https-URL + // Web-URLs erkennt der Sync 1:1 aus `Adressen.InternetAdresse`; falls + // dort kein Schema steht (häufiger Fall: „www.kunde.de"), prefixen wir + // https://. Sonst öffnet `launchUrl` mit relativem URI auf Android nichts. + ({IconData icon, String semantics, Uri? uri}) _action() { + switch (channel.kind) { + case ContactKind.phone: + case ContactKind.mobile: + return ( + icon: channel.kind == ContactKind.mobile + ? Icons.smartphone + : Icons.call, + semantics: 'Anrufen', + uri: Uri(scheme: 'tel', path: channel.value), + ); + case ContactKind.email: + return ( + icon: Icons.mail_outline, + semantics: 'E-Mail schreiben', + uri: Uri(scheme: 'mailto', path: channel.value), + ); + case ContactKind.web: + final raw = channel.value; + final normalized = raw.startsWith(RegExp(r'https?://')) + ? raw + : 'https://$raw'; + return ( + icon: Icons.public, + semantics: 'Webseite öffnen', + uri: Uri.tryParse(normalized), + ); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final action = _action(); + final hasUri = action.uri != null; + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + children: [ + Icon(action.icon, size: 18, color: theme.colorScheme.primary), + const SizedBox(width: 8), + Expanded( + child: Text( + channel.value, + style: const TextStyle(fontSize: 14), + ), + ), + if (hasUri) + IconButton( + icon: Icon(action.icon, color: theme.colorScheme.primary), + tooltip: action.semantics, + onPressed: () => + onLaunch(context, action.uri!, action.semantics), + ), + ], + ), + ); + } +} + +/// Auswahl-Eintrag für den „Anrufen"-Quick-Action — kapselt eine +/// anrufbare Nummer mit dem nötigen Kontext (Rolle / Name / Icon), aus +/// dem das Bottom-Sheet seine Zeilen baut. Bewusst nur View-lokal: +/// die Domain kennt nur Sources und Channels, das Sheet braucht eine +/// flache, vorgefilterte Liste. +class _CallableNumber { + const _CallableNumber({ + required this.value, + required this.kind, + required this.roleLabel, + required this.displayName, + }); + + final String value; + final ContactKind kind; + final String roleLabel; + final String? displayName; + + /// Bei einer Dublette (gleiche Nummer aus mehreren Quellen) hängen wir + /// die zweite Rolle ans Label. Duplikate werden vor dem `withAdditional` + /// schon im Normalisierungs-Check abgefangen, sodass dasselbe Label nie + /// zweimal in [roleLabel] landet. + _CallableNumber withAdditionalRole(String additionalRoleLabel) { + if (roleLabel.contains(additionalRoleLabel)) return this; + return _CallableNumber( + value: value, + kind: kind, + roleLabel: '$roleLabel · $additionalRoleLabel', + displayName: displayName, + ); + } +} diff --git a/lib/feature/delivery/detail/presentation/steps/step_note.dart b/lib/feature/delivery/detail/presentation/steps/step_note.dart deleted file mode 100644 index a973b1e..0000000 --- a/lib/feature/delivery/detail/presentation/steps/step_note.dart +++ /dev/null @@ -1,86 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:hl_lieferservice/feature/delivery/detail/bloc/note_bloc.dart'; -import 'package:hl_lieferservice/feature/delivery/detail/bloc/note_event.dart'; -import 'package:hl_lieferservice/feature/delivery/detail/bloc/note_state.dart'; -import 'package:hl_lieferservice/feature/delivery/detail/model/note.dart'; -import 'package:hl_lieferservice/feature/delivery/detail/presentation/note/note_fail_page.dart'; -import 'package:hl_lieferservice/feature/delivery/detail/presentation/note/note_overview.dart'; -import 'package:hl_lieferservice/model/delivery.dart'; - -class DeliveryStepNote extends StatefulWidget { - final Delivery delivery; - - const DeliveryStepNote({required this.delivery, super.key}); - - @override - State createState() => _DeliveryStepInfo(); -} - -class _DeliveryStepInfo extends State { - @override - void initState() { - super.initState(); - context.read().add(LoadNote(delivery: widget.delivery)); - } - - Widget _notesLoading() { - return Center(child: CircularProgressIndicator()); - } - - Widget _blocUndefinedState() { - return Center(child: const Text("NoteBloc in einem Fehlerhaften Zustand")); - } - - Widget _notesOverview( - BuildContext context, - List notes, - List templates, - List images, - ) { - List hydratedNotes = - notes - .map( - (note) => NoteInformation( - note: note, - article: widget.delivery.findArticleWithNoteId( - note.id.toString(), - ), - ), - ) - .toList(); - - return NoteOverview( - notes: hydratedNotes, - deliveryId: widget.delivery.id, - templates: templates, - images: images, - ); - } - - @override - Widget build(BuildContext context) { - return BlocBuilder( - builder: (context, state) { - if (state is NoteLoading) { - return _notesLoading(); - } - - if (state is NoteLoaded) { - return _notesOverview( - context, - state.notes, - (state.templates ?? []), - (state.images ?? []), - ); - } - - if (state is NoteLoadingFailed) { - return NoteLoadingFailPage(delivery: widget.delivery); - } - - return _blocUndefinedState(); - }, - ); - } -} diff --git a/lib/feature/delivery/detail/presentation/steps/step_notes.dart b/lib/feature/delivery/detail/presentation/steps/step_notes.dart new file mode 100644 index 0000000..9396ec3 --- /dev/null +++ b/lib/feature/delivery/detail/presentation/steps/step_notes.dart @@ -0,0 +1,716 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:image_picker/image_picker.dart'; + +import 'package:hl_lieferservice/domain/entity/delivery.dart'; +import 'package:hl_lieferservice/domain/entity/delivery_note.dart'; +import 'package:hl_lieferservice/domain/entity/tour_details.dart'; +import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.dart'; +import 'package:hl_lieferservice/feature/delivery/bloc/tour_event.dart'; +import 'package:hl_lieferservice/widget/attachment_image.dart'; + +/// Step 2 — Notizen & Fotos. +/// +/// Die UI trennt bewusst in **zwei Sektionen**, weil es zwei +/// unterschiedliche Dinge sind: +/// * **Notizen** (Text): anlegen / bearbeiten / löschen über den +/// `TourBloc` (Backend-Endpoints vorhanden). +/// * **Fotos** (Bild-Notizen): `image_picker` → Upload über +/// `TourBloc.UploadDeliveryNoteImage`. Das Backend schiebt das Bild nach +/// DOCUframe und legt eine Notiz mit der Referenz an. Fotos werden als +/// Thumbnail angezeigt (Tap → formatfüllend) und können nur gelöscht, +/// nicht inline bearbeitet werden. +/// +/// Datenmodell: beides sind `DeliveryNote`s. Unterschieden wird über +/// `imageAttachment != null` (Foto) bzw. `text != null` (Notiz). +class StepNotes extends StatelessWidget { + const StepNotes({super.key, required this.delivery, required this.details}); + + final Delivery delivery; + final TourDetails details; + + void _openAddNoteDialog(BuildContext context) { + final tourBloc = context.read(); + showDialog( + context: context, + builder: (_) => _NoteEditorDialog( + title: 'Notiz hinzufügen', + onSubmit: (text) => tourBloc.add( + AddDeliveryNote(deliveryId: delivery.id, text: text), + ), + ), + ); + } + + Future _pickImage(BuildContext context) async { + // Bloc vor dem await greifen — danach kein context-Zugriff über den + // async-Gap. + final tourBloc = context.read(); + final picker = ImagePicker(); + // Bild schon on-device runterskalieren + JPEG-komprimieren: Foto-Notizen + // brauchen keine 12-MP-Originale. Spart Upload-/Speicher-/Report-Größe + // (ein 4080×3060-Foto ~2,9 MB → ~200–400 KB). 1600 px / Q82 deckt sich mit + // dem Backend-Report-Renderer. + final file = await picker.pickImage( + source: ImageSource.camera, + maxWidth: 1600, + maxHeight: 1600, + imageQuality: 82, + ); + if (file == null) return; + final bytes = await file.readAsBytes(); + tourBloc.add( + UploadDeliveryNoteImage( + deliveryId: delivery.id, + filename: file.name, + mime: file.mimeType ?? _mimeFromName(file.name), + bytes: bytes, + ), + ); + } + + /// Grober MIME-Fallback, wenn der Picker keinen Typ liefert (Kamera gibt + /// meist JPEG). Reicht für das `Content-Type` des Multipart-Felds. + String _mimeFromName(String name) { + final lower = name.toLowerCase(); + if (lower.endsWith('.png')) return 'image/png'; + if (lower.endsWith('.heic')) return 'image/heic'; + if (lower.endsWith('.webp')) return 'image/webp'; + return 'image/jpeg'; + } + + @override + Widget build(BuildContext context) { + final notes = details.notesOf(delivery.id); + // Notizen & Fotos sind nur bei aktiver Lieferung änderbar. Ist die + // Lieferung beendet (abgeschlossen/abgebrochen/pausiert), bleiben sie + // sichtbar, aber read-only: kein FAB, keine Aktions-Menüs, kein Löschen. + final active = delivery.state == DeliveryState.active; + // Sauber in Text-Notizen und Fotos aufteilen — getrennte Sektionen. + final textNotes = + notes.where((n) => n.imageAttachment == null).toList(growable: false); + final photoNotes = + notes.where((n) => n.imageAttachment != null).toList(growable: false); + return Stack( + children: [ + ListView( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 100), + children: [ + if (!active) ...[ + const _ReadOnlyBanner(), + const SizedBox(height: 16), + ], + _SectionHeader(text: 'Notizen (${textNotes.length})'), + const SizedBox(height: 8), + if (textNotes.isEmpty) + const _EmptyHint( + icon: Icons.notes, + text: 'Noch keine Notizen erfasst.', + ) + else + for (final n in textNotes) + _NoteCard(note: n, deliveryId: delivery.id, active: active), + const SizedBox(height: 24), + _SectionHeader(text: 'Fotos (${photoNotes.length})'), + const SizedBox(height: 8), + if (photoNotes.isEmpty) + const _EmptyHint( + icon: Icons.photo_camera_outlined, + text: 'Noch keine Fotos aufgenommen.', + ) + else + for (final n in photoNotes) + _PhotoCard(note: n, deliveryId: delivery.id, active: active), + ], + ), + // FAB nur bei aktiver Lieferung — sonst ist Hinzufügen gesperrt. + if (active) + Align( + alignment: Alignment.bottomRight, + child: Padding( + padding: const EdgeInsets.all(16), + child: _AddMenu( + onAddNote: () => _openAddNoteDialog(context), + onAddImage: () => _pickImage(context), + ), + ), + ), + ], + ); + } +} + +/// Hinweis-Balken oben in der Notiz-Sektion, wenn die Lieferung nicht mehr +/// aktiv ist — Notizen/Fotos sind dann reine Anzeige. +class _ReadOnlyBanner extends StatelessWidget { + const _ReadOnlyBanner(); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Icon(Icons.lock_outline, + size: 18, color: theme.colorScheme.onSurfaceVariant), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Lieferung beendet — Notizen & Fotos können nicht mehr ' + 'hinzugefügt, geändert oder gelöscht werden.', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + ); + } +} + +class _SectionHeader extends StatelessWidget { + const _SectionHeader({required this.text}); + final String text; + + @override + Widget build(BuildContext context) { + return Text( + text, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ); + } +} + +class _EmptyHint extends StatelessWidget { + const _EmptyHint({required this.icon, required this.text}); + final IconData icon; + final String text; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Card( + margin: EdgeInsets.zero, + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Icon(icon, color: theme.colorScheme.onSurfaceVariant), + const SizedBox(width: 12), + Text( + text, + style: TextStyle( + color: theme.colorScheme.onSurfaceVariant, + fontStyle: FontStyle.italic, + ), + ), + ], + ), + ), + ); + } +} + +enum _NoteAction { edit, delete } + +/// Geteiltes Zeitformat für Notiz- und Foto-Karten. +String _formatNoteTime(DateTime t) => + '${t.day.toString().padLeft(2, "0")}.${t.month.toString().padLeft(2, "0")}.${t.year} ' + '${t.hour.toString().padLeft(2, "0")}:${t.minute.toString().padLeft(2, "0")}'; + +/// Geteilter Lösch-Bestätigungsdialog. Wording variiert je nachdem, ob eine +/// Text-Notiz oder ein Foto entfernt wird; gefeuert wird in beiden Fällen +/// dasselbe `DeleteDeliveryNote`-Event (Foto ist intern auch eine Notiz). +Future _confirmDeleteNote( + BuildContext context, { + required String deliveryId, + required String noteId, + required bool isPhoto, +}) async { + final tourBloc = context.read(); + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(isPhoto ? 'Foto löschen?' : 'Notiz löschen?'), + content: Text( + isPhoto + ? 'Das Foto wird dauerhaft entfernt.' + : 'Die Notiz wird dauerhaft entfernt.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('Abbrechen'), + ), + FilledButton( + style: FilledButton.styleFrom( + backgroundColor: Theme.of(ctx).colorScheme.error, + foregroundColor: Theme.of(ctx).colorScheme.onError, + ), + onPressed: () => Navigator.of(ctx).pop(true), + child: const Text('Löschen'), + ), + ], + ), + ); + if (confirmed != true) return; + tourBloc.add(DeleteDeliveryNote(deliveryId: deliveryId, noteId: noteId)); +} + +/// Karte einer **Text-Notiz**. Normale Notizen sind bearbeitbar und löschbar. +/// **System-verwaltete** Grund-Notizen (Mengen-Gutschrift via +/// `creditDeliveryItemId` oder Betrags-Gutschrift via `isAmountCreditNote`) +/// dürfen vom Fahrer nicht manuell geändert/gelöscht werden — sie werden +/// automatisch mit der jeweiligen Gutschrift angelegt und wieder entfernt. +class _NoteCard extends StatelessWidget { + const _NoteCard({ + required this.note, + required this.deliveryId, + required this.active, + }); + final DeliveryNote note; + final String deliveryId; + + /// Nur bei aktiver Lieferung darf bearbeitet/gelöscht werden. + final bool active; + + void _openEditDialog(BuildContext context) { + final tourBloc = context.read(); + showDialog( + context: context, + builder: (_) => _NoteEditorDialog( + title: 'Notiz bearbeiten', + initialText: note.text, + onSubmit: (text) => tourBloc.add( + UpdateDeliveryNote( + deliveryId: deliveryId, + noteId: note.id, + text: text, + ), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + // System-verwaltete Grund-Notiz: kein manuelles Bearbeiten/Löschen. + final isSystemManaged = + note.creditDeliveryItemId != null || note.isAmountCreditNote; + // Aktions-Menü nur bei aktiver Lieferung UND nicht-System-Notiz. + final canEdit = active && !isSystemManaged; + return Card( + margin: const EdgeInsets.only(bottom: 8), + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 12, 4, 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.only(top: 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + note.text ?? '', + style: const TextStyle(fontSize: 14), + ), + const SizedBox(height: 6), + Text( + 'Personalnr. ${note.authorPersonalnummer} ' + '· ${_formatNoteTime(note.createdAt)}', + style: TextStyle( + fontSize: 11, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ), + if (!canEdit) + Padding( + padding: const EdgeInsets.only(right: 8, top: 6), + child: Tooltip( + message: isSystemManaged + ? 'Automatisch verwaltet – wird mit der Gutschrift ' + 'angelegt und beim Zurücknehmen wieder entfernt.' + : 'Lieferung beendet – Notiz nicht mehr änderbar.', + child: Icon( + Icons.lock_outline, + size: 18, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ) + else + PopupMenuButton<_NoteAction>( + icon: const Icon(Icons.more_vert), + tooltip: 'Notiz-Aktionen', + onSelected: (action) { + switch (action) { + case _NoteAction.edit: + _openEditDialog(context); + case _NoteAction.delete: + _confirmDeleteNote( + context, + deliveryId: deliveryId, + noteId: note.id, + isPhoto: false, + ); + } + }, + itemBuilder: (context) => [ + const PopupMenuItem( + value: _NoteAction.edit, + child: Row( + children: [ + Icon(Icons.edit_outlined), + SizedBox(width: 12), + Text('Bearbeiten'), + ], + ), + ), + PopupMenuItem( + value: _NoteAction.delete, + child: Row( + children: [ + Icon(Icons.delete_outline, + color: theme.colorScheme.error), + const SizedBox(width: 12), + Text( + 'Löschen', + style: TextStyle(color: theme.colorScheme.error), + ), + ], + ), + ), + ], + ), + ], + ), + ), + ); + } +} + +/// Karte eines **Fotos** — Thumbnail (Tap → formatfüllend) plus Metazeile +/// mit Lösch-Button. Kein Inline-Edit (ein Foto bearbeitet man nicht). +class _PhotoCard extends StatelessWidget { + const _PhotoCard({ + required this.note, + required this.deliveryId, + required this.active, + }); + final DeliveryNote note; + final String deliveryId; + + /// Nur bei aktiver Lieferung darf das Foto gelöscht werden. + final bool active; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Card( + margin: const EdgeInsets.only(bottom: 8), + clipBehavior: Clip.antiAlias, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _NoteImageThumb( + attachmentId: note.imageAttachment!, + deleted: note.imageAttachmentDeleted, + ), + Padding( + padding: const EdgeInsets.fromLTRB(12, 8, 4, 8), + child: Row( + children: [ + Icon( + Icons.photo_camera_outlined, + size: 16, + color: theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 6), + Expanded( + child: Text( + 'Personalnr. ${note.authorPersonalnummer} ' + '· ${_formatNoteTime(note.createdAt)}', + style: TextStyle( + fontSize: 11, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + if (active) + IconButton( + icon: Icon( + Icons.delete_outline, + color: theme.colorScheme.error, + ), + tooltip: 'Foto löschen', + onPressed: () => _confirmDeleteNote( + context, + deliveryId: deliveryId, + noteId: note.id, + isPhoto: true, + ), + ) + else + Padding( + padding: const EdgeInsets.only(right: 8), + child: Tooltip( + message: 'Lieferung beendet – Foto nicht mehr löschbar.', + child: Icon( + Icons.lock_outline, + size: 18, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +/// Thumbnail einer Bild-Notiz; Tap öffnet das Bild formatfüllend mit +/// Zoom/Pan. +class _NoteImageThumb extends StatelessWidget { + const _NoteImageThumb({required this.attachmentId, this.deleted = false}); + + final String attachmentId; + + /// Lokale Bilddatei nach Report-Upload gelöscht → Hinweis statt Vorschau. + final bool deleted; + + void _openFull(BuildContext context) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => Scaffold( + backgroundColor: Colors.black, + appBar: AppBar( + backgroundColor: Colors.black, + foregroundColor: Colors.white, + ), + body: Center( + child: InteractiveViewer( + minScale: 0.5, + maxScale: 5, + child: AttachmentImage( + attachmentId: attachmentId, + width: 2048, + height: 2048, + quality: 90, + fit: BoxFit.contain, + deleted: deleted, + ), + ), + ), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + // Kein eigenes Clipping — die umgebende `_PhotoCard` clippt bereits + // (Clip.antiAlias), sonst gäbe es doppelt gerundete Ecken. + return GestureDetector( + // Gelöschtes Bild → kein Vollbild öffnen (es gibt nichts zu laden). + onTap: deleted ? null : () => _openFull(context), + child: SizedBox( + height: 160, + width: double.infinity, + child: AttachmentImage( + attachmentId: attachmentId, + width: 600, + height: 600, + deleted: deleted, + ), + ), + ); + } +} + +// ─── Add-Menu (FAB) ───────────────────────────────────────────────────── + +enum _AddAction { note, image } + +class _AddMenu extends StatelessWidget { + const _AddMenu({required this.onAddNote, required this.onAddImage}); + + final VoidCallback onAddNote; + final VoidCallback onAddImage; + + @override + Widget build(BuildContext context) { + return PopupMenuButton<_AddAction>( + tooltip: 'Hinzufügen', + onSelected: (action) { + switch (action) { + case _AddAction.note: + onAddNote(); + case _AddAction.image: + onAddImage(); + } + }, + itemBuilder: (context) => const [ + PopupMenuItem( + value: _AddAction.note, + child: Row( + children: [ + Icon(Icons.edit_note), + SizedBox(width: 12), + Text('Notiz schreiben'), + ], + ), + ), + PopupMenuItem( + value: _AddAction.image, + child: Row( + children: [ + Icon(Icons.camera_alt_outlined), + SizedBox(width: 12), + Text('Foto aufnehmen'), + ], + ), + ), + ], + child: FloatingActionButton.extended( + onPressed: null, // PopupMenuButton fängt den Tap + icon: const Icon(Icons.add), + label: const Text('Hinzufügen'), + ), + ); + } +} + +// ─── Notiz-Dialog (Text) ──────────────────────────────────────────────── + +/// Editor-Dialog für Text-Notizen — geteilt zwischen „Hinzufügen" und +/// „Bearbeiten". Liefert den getrimmten Text per [onSubmit]; der Aufrufer +/// entscheidet, ob daraus ein `AddDeliveryNote` oder `UpdateDeliveryNote` +/// wird. +class _NoteEditorDialog extends StatefulWidget { + const _NoteEditorDialog({ + required this.title, + required this.onSubmit, + this.initialText, + }); + + final String title; + final void Function(String text) onSubmit; + final String? initialText; + + @override + State<_NoteEditorDialog> createState() => _NoteEditorDialogState(); +} + +class _NoteEditorDialogState extends State<_NoteEditorDialog> { + late final TextEditingController _controller = + TextEditingController(text: widget.initialText ?? ''); + bool _empty = true; + + @override + void initState() { + super.initState(); + _empty = _controller.text.trim().isEmpty; + _controller.addListener(() { + setState(() => _empty = _controller.text.trim().isEmpty); + }); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _save() { + final text = _controller.text.trim(); + if (text.isEmpty) return; + widget.onSubmit(text); + Navigator.of(context).pop(); + } + + @override + Widget build(BuildContext context) { + return Dialog( + insetPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24), + child: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.55, + ), + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Expanded( + child: Text( + widget.title, + style: Theme.of(context).textTheme.titleLarge, + ), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + const SizedBox(height: 12), + // TODO(B6): Templates-Dropdown ergänzen, sobald Backend + // Notiz-Templates als Stammdaten anbietet. + Expanded( + child: TextField( + controller: _controller, + autofocus: true, + maxLines: null, + expands: true, + textAlignVertical: TextAlignVertical.top, + decoration: const InputDecoration( + labelText: 'Notiz', + border: OutlineInputBorder(), + alignLabelWithHint: true, + ), + ), + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Abbrechen'), + ), + const SizedBox(width: 8), + FilledButton.icon( + onPressed: _empty ? null : _save, + icon: const Icon(Icons.save), + label: const Text('Speichern'), + ), + ], + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/feature/delivery/detail/presentation/steps/step_services.dart b/lib/feature/delivery/detail/presentation/steps/step_services.dart new file mode 100644 index 0000000..cb2c122 --- /dev/null +++ b/lib/feature/delivery/detail/presentation/steps/step_services.dart @@ -0,0 +1,324 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'package:hl_lieferservice/domain/entity/delivery.dart'; +import 'package:hl_lieferservice/domain/entity/delivery_service_value.dart'; +import 'package:hl_lieferservice/domain/entity/service.dart'; +import 'package:hl_lieferservice/domain/entity/tour_details.dart'; +import 'package:hl_lieferservice/feature/car_selection/bloc/bloc.dart'; +import 'package:hl_lieferservice/feature/car_selection/bloc/state.dart'; +import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.dart'; +import 'package:hl_lieferservice/feature/delivery/bloc/tour_event.dart'; +import 'package:hl_lieferservice/feature/delivery/bloc/tour_state.dart'; + +/// Step 4 — Services (früher „Lieferoptionen"). +/// +/// Rendert die aktiven Service-Definitionen (`TourDetails.services`, +/// admin-konfigurierbar) und lässt den Fahrer sie pro Lieferung auswählen: +/// `boolean` → Checkbox, `numeric` → Zahlenfeld mit min/max. Werte landen +/// über den `TourBloc` (`SetDeliveryServiceValue`/`RemoveDeliveryServiceValue`) +/// im Backend. Setzen nur bei aktiver Lieferung. +class StepServices extends StatelessWidget { + const StepServices({super.key, required this.delivery, required this.details}); + + final Delivery delivery; + final TourDetails details; + + String _actorCarId(BuildContext context) { + final state = context.read().state; + if (state is CarSelectComplete) return state.selectedCar.id; + return '00000000-0000-0000-0000-000000000000'; + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final active = delivery.state == DeliveryState.active; + + return BlocBuilder( + buildWhen: (a, b) { + if (a is! TourLoaded || b is! TourLoaded) return true; + return a.details.services != b.details.services || + a.details.serviceValuesByDeliveryId[delivery.id] != + b.details.serviceValuesByDeliveryId[delivery.id]; + }, + builder: (context, state) { + final d = state is TourLoaded ? state.details : details; + final services = d.services; + + if (services.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.construction_outlined, + size: 56, color: theme.colorScheme.onSurfaceVariant), + const SizedBox(height: 12), + Text('Keine Services konfiguriert', + style: theme.textTheme.titleMedium), + const SizedBox(height: 4), + Text( + 'Ein Administrator kann Services anlegen.', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ); + } + + // Wie im alten `delivery_options.dart`: zwei Kategorien — + // „Auswählbare Optionen" (Checkboxen) und „Zahlenwerte". + final bools = + services.where((s) => s.kind == ServiceKind.boolean).toList(); + final numerics = + services.where((s) => s.kind == ServiceKind.numeric).toList(); + + _ServiceTile tileFor(Service service) => _ServiceTile( + service: service, + value: d.serviceValueOf(delivery.id, service.id), + enabled: active, + onSetBool: (v) => context.read().add( + SetDeliveryServiceValue( + deliveryId: delivery.id, + serviceId: service.id, + boolValue: v, + actorCarId: _actorCarId(context), + ), + ), + onSetNumeric: (n) => context.read().add( + SetDeliveryServiceValue( + deliveryId: delivery.id, + serviceId: service.id, + numericValue: n, + actorCarId: _actorCarId(context), + ), + ), + onClear: () => context.read().add( + RemoveDeliveryServiceValue( + deliveryId: delivery.id, + serviceId: service.id, + ), + ), + ); + + Widget sectionCard(List items) => Card( + margin: EdgeInsets.zero, + child: Column( + children: [ + for (int i = 0; i < items.length; i++) ...[ + tileFor(items[i]), + if (i < items.length - 1) + const Divider(height: 1, indent: 16, endIndent: 16), + ], + ], + ), + ); + + return ListView( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 24), + children: [ + if (!active) + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Row( + children: [ + Icon(Icons.lock_outline, + size: 16, color: theme.colorScheme.onSurfaceVariant), + const SizedBox(width: 6), + Text( + 'Nur bei aktiver Lieferung änderbar.', + style: TextStyle( + fontSize: 12, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + if (bools.isNotEmpty) ...[ + const _CategoryHeader( + icon: Icons.check_box_outlined, + text: 'Auswählbare Optionen', + ), + const SizedBox(height: 8), + sectionCard(bools), + ], + if (bools.isNotEmpty && numerics.isNotEmpty) + const SizedBox(height: 24), + if (numerics.isNotEmpty) ...[ + const _CategoryHeader( + icon: Icons.pin_outlined, + text: 'Zahlenwerte', + ), + const SizedBox(height: 8), + sectionCard(numerics), + ], + ], + ); + }, + ); + } +} + +/// Kategorie-Überschrift (Icon + Titel) — trennt Checkboxen von Zahlenwerten. +class _CategoryHeader extends StatelessWidget { + const _CategoryHeader({required this.icon, required this.text}); + final IconData icon; + final String text; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Row( + children: [ + Icon(icon, size: 18, color: theme.colorScheme.primary), + const SizedBox(width: 8), + Text( + text, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ], + ); + } +} + +/// Eine Service-Zeile — Checkbox (boolean) oder Zahlenfeld (numeric). +class _ServiceTile extends StatelessWidget { + const _ServiceTile({ + required this.service, + required this.value, + required this.enabled, + required this.onSetBool, + required this.onSetNumeric, + required this.onClear, + }); + + final Service service; + final DeliveryServiceValue? value; + final bool enabled; + final ValueChanged onSetBool; + final ValueChanged onSetNumeric; + final VoidCallback onClear; + + @override + Widget build(BuildContext context) { + switch (service.kind) { + case ServiceKind.boolean: + return CheckboxListTile( + value: value?.boolValue ?? false, + onChanged: enabled ? (v) => onSetBool(v ?? false) : null, + title: Text(service.name), + controlAffinity: ListTileControlAffinity.leading, + dense: true, + ); + case ServiceKind.numeric: + return _NumericServiceField( + key: ValueKey(service.id), + service: service, + initial: value?.numericValue, + enabled: enabled, + onSetNumeric: onSetNumeric, + onClear: onClear, + ); + } + } +} + +/// Zahlenfeld eines numerischen Service — eigener Controller, persistiert beim +/// Verlassen/Submit, klemmt auf [min,max]. Leeres Feld → Wert entfernen. +class _NumericServiceField extends StatefulWidget { + const _NumericServiceField({ + super.key, + required this.service, + required this.initial, + required this.enabled, + required this.onSetNumeric, + required this.onClear, + }); + + final Service service; + final int? initial; + final bool enabled; + final ValueChanged onSetNumeric; + final VoidCallback onClear; + + @override + State<_NumericServiceField> createState() => _NumericServiceFieldState(); +} + +class _NumericServiceFieldState extends State<_NumericServiceField> { + late final TextEditingController _controller = + TextEditingController(text: widget.initial?.toString() ?? ''); + + @override + void didUpdateWidget(_NumericServiceField old) { + super.didUpdateWidget(old); + // Server-Stand übernehmen, wenn er sich geändert hat (z. B. nach + // Reconcile) und sich vom angezeigten Text unterscheidet. + final incoming = widget.initial?.toString() ?? ''; + if (old.initial != widget.initial && _controller.text != incoming) { + _controller.text = incoming; + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _commit() { + final raw = _controller.text.trim(); + if (raw.isEmpty) { + widget.onClear(); + return; + } + final parsed = int.tryParse(raw); + if (parsed == null) { + _controller.text = widget.initial?.toString() ?? ''; + return; + } + var n = parsed; + final min = widget.service.minValue; + final max = widget.service.maxValue; + if (min != null && n < min) n = min; + if (max != null && n > max) n = max; + if (n.toString() != _controller.text) { + _controller.text = n.toString(); + } + widget.onSetNumeric(n); + } + + @override + Widget build(BuildContext context) { + final s = widget.service; + return Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + child: TextField( + controller: _controller, + enabled: widget.enabled, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + textInputAction: TextInputAction.done, + decoration: InputDecoration( + labelText: s.name, + border: const OutlineInputBorder(), + isDense: true, + ), + onTapOutside: (_) { + FocusScope.of(context).unfocus(); + _commit(); + }, + onSubmitted: (_) => _commit(), + ), + ); + } +} diff --git a/lib/feature/delivery/detail/presentation/steps/step_summary.dart b/lib/feature/delivery/detail/presentation/steps/step_summary.dart index 71e8e38..cc218bb 100644 --- a/lib/feature/delivery/detail/presentation/steps/step_summary.dart +++ b/lib/feature/delivery/detail/presentation/steps/step_summary.dart @@ -1,19 +1,514 @@ import 'package:flutter/material.dart'; -import 'package:hl_lieferservice/feature/delivery/detail/presentation/delivery_summary.dart'; -import 'package:hl_lieferservice/model/delivery.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'package:hl_lieferservice/domain/entity/delivery.dart'; +import 'package:hl_lieferservice/domain/entity/delivery_credit.dart'; +import 'package:hl_lieferservice/domain/entity/delivery_item.dart'; +import 'package:hl_lieferservice/domain/entity/tour_details.dart'; +import 'package:hl_lieferservice/feature/delivery/detail/bloc/workflow_bloc.dart'; +import 'package:hl_lieferservice/feature/delivery/detail/bloc/workflow_event.dart'; +import 'package:hl_lieferservice/feature/delivery/detail/bloc/workflow_state.dart'; +import 'package:hl_lieferservice/feature/payment_methods/bloc/payment_methods_cubit.dart'; + +/// Step 5 — Übersicht & Abschluss. +/// +/// Listet alle Artikel mit der **tatsächlich auszuliefernden Menge** auf +/// (Original-Soll minus lokaler Partial-Remove-Drafts minus +/// Komplett-Removes). Dazu Anzahlung-Anzeige, optionale Gutschrift, +/// Zahlungsmethoden-Dropdown. +/// +/// Der „Unterschreiben"-Button lebt in der Bottom-Navigation des +/// Page-Wrappers; hier zeigen wir den Resümee-Block, der direkt vor der +/// Unterschrift steht. +class StepSummary extends StatelessWidget { + const StepSummary({ + super.key, + required this.delivery, + required this.details, + }); -class DeliveryStepSummary extends StatefulWidget { final Delivery delivery; + final TourDetails details; - const DeliveryStepSummary({required this.delivery, super.key}); - - @override - State createState() => _DeliveryStepInfo(); -} - -class _DeliveryStepInfo extends State { @override Widget build(BuildContext context) { - return DeliverySummary(delivery: widget.delivery); + return BlocBuilder( + builder: (context, wfState) { + return ListView( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 24), + children: [ + _SectionHeader(text: 'Ausgelieferte Artikel'), + const SizedBox(height: 8), + _DeliveredItems( + delivery: delivery, + details: details, + ), + const SizedBox(height: 24), + _SectionHeader(text: 'Zahlung'), + const SizedBox(height: 8), + _PaymentSummary( + delivery: delivery, + credit: details.creditOf(delivery.id), + ), + const SizedBox(height: 24), + _SectionHeader(text: 'Zahlungsmethode'), + const SizedBox(height: 8), + _PaymentMethodPicker( + delivery: delivery, + overrideId: wfState.paymentMethodOverrideId, + ), + const SizedBox(height: 16), + const _SignHint(), + ], + ); + }, + ); + } +} + +class _SectionHeader extends StatelessWidget { + const _SectionHeader({required this.text}); + final String text; + + @override + Widget build(BuildContext context) { + return Text( + text, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ); + } +} + +class _DeliveredItems extends StatelessWidget { + const _DeliveredItems({ + required this.delivery, + required this.details, + }); + + final Delivery delivery; + final TourDetails details; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + // Innerhalb einer Belegzeile: Oberartikel vor seinen Komponenten, damit + // Komponenten direkt darunter eingerückt erscheinen. + final items = List.of(delivery.items) + ..sort((a, b) { + final byLine = a.belegzeilenNr.compareTo(b.belegzeilenNr); + if (byLine != 0) return byLine; + final byParent = + (a.isComponent ? 1 : 0).compareTo(b.isComponent ? 1 : 0); + if (byParent != 0) return byParent; + return (a.komponentenArtikelNr ?? '') + .compareTo(b.komponentenArtikelNr ?? ''); + }); + if (items.isEmpty) { + return Card( + margin: EdgeInsets.zero, + child: Padding( + padding: const EdgeInsets.all(16), + child: Text( + 'Keine Artikel hinterlegt.', + style: TextStyle( + color: theme.colorScheme.onSurfaceVariant, + fontStyle: FontStyle.italic, + ), + ), + ), + ); + } + return Card( + margin: EdgeInsets.zero, + child: Column( + children: [ + for (int i = 0; i < items.length; i++) ...[ + _DeliveredRow( + item: items[i], + details: details, + ), + if (i < items.length - 1) + const Divider(height: 1, indent: 16, endIndent: 16), + ], + ], + ), + ); + } +} + +class _DeliveredRow extends StatelessWidget { + const _DeliveredRow({ + required this.item, + required this.details, + }); + + final DeliveryItem item; + final TourDetails details; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final article = details.articleOf(item.articleId); + // Ausgeliefert = Soll − Gutschrift (vom Backend). Voll gutgeschrieben + // (status removed) ⇒ credited == required ⇒ delivered 0. + final credited = item.scanProgress.creditedQuantity; + final delivered = (item.requiredQuantity - credited).clamp( + 0, + item.requiredQuantity, + ); + + final Color avatarColor; + if (delivered == 0) { + avatarColor = Colors.red.shade400; + } else if (delivered < item.requiredQuantity) { + avatarColor = Colors.amber.shade700; + } else { + avatarColor = Colors.green.shade600; + } + + return ListTile( + // Komponenten um eine Stufe eingerückt (gehören zum Oberartikel darüber). + contentPadding: + EdgeInsets.only(left: item.isComponent ? 40 : 16, right: 16), + leading: CircleAvatar( + backgroundColor: avatarColor, + foregroundColor: theme.colorScheme.onPrimary, + child: Text( + '$delivered×', + style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold), + ), + ), + title: Text( + '${item.isComponent ? '↳ ' : ''}${article?.name ?? '⟨Unbekannter Artikel⟩'}', + style: TextStyle( + fontWeight: FontWeight.w600, + decoration: delivered == 0 ? TextDecoration.lineThrough : null, + color: delivered == 0 ? theme.colorScheme.onSurfaceVariant : null, + ), + ), + subtitle: Text( + [ + if (delivered < item.requiredQuantity) + 'von ${item.requiredQuantity} bestellt · Gutschrift: $credited' + else + 'Artikelnr. ${article?.articleNumber ?? item.articleId}', + '${item.unitPrice.toStringAsFixed(2)} € / Stück', + ].join(' · '), + style: TextStyle( + fontSize: 12, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + trailing: Text( + '${item.lineTotal.toStringAsFixed(2)} €', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + decoration: delivered == 0 ? TextDecoration.lineThrough : null, + color: delivered == 0 ? theme.colorScheme.onSurfaceVariant : null, + ), + ), + ); + } +} + +class _PaymentSummary extends StatelessWidget { + const _PaymentSummary({required this.delivery, required this.credit}); + + final Delivery delivery; + final DeliveryCredit? credit; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + // Exakt aus Cent (nicht gerundet) — Gutschrift kann Cent-Beträge haben. + final creditEuros = (credit?.amountCents ?? 0) / 100.0; + // Warenwert = Σ Stückpreis × ausgelieferte Menge (entfernte/teil-entfernte + // Positionen fallen automatisch raus). + final warenwert = delivery.items + .fold(0, (acc, item) => acc + item.lineTotal); + // Offener Betrag = Warenwert − Anzahlung − Gutschrift, nie negativ. + final open = (warenwert - delivery.prepaidAmount - creditEuros) + .clamp(0.0, double.infinity); + return Card( + margin: EdgeInsets.zero, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + _SummaryRow( + icon: Icons.receipt_long_outlined, + label: 'Warenwert', + valueText: '${warenwert.toStringAsFixed(2)} €', + valueColor: theme.colorScheme.onSurface, + ), + const SizedBox(height: 12), + _SummaryRow( + icon: Icons.savings_outlined, + label: 'Bei Bestellung bezahlt', + valueText: '− ${delivery.prepaidAmount.toStringAsFixed(2)} €', + valueColor: delivery.prepaidAmount > 0 + ? Colors.green.shade700 + : theme.colorScheme.onSurfaceVariant, + ), + if (credit != null) ...[ + const SizedBox(height: 12), + _SummaryRow( + icon: Icons.card_giftcard_outlined, + label: 'Gutschrift', + valueText: '− ${(credit!.amountCents / 100).toStringAsFixed(2)} €', + valueColor: Colors.amber.shade800, + subtitle: credit!.reason, + ), + ], + const Divider(height: 24), + _SummaryRow( + icon: Icons.account_balance_wallet_outlined, + label: 'Offener Betrag', + valueText: '${open.toStringAsFixed(2)} €', + valueColor: open > 0 + ? theme.colorScheme.primary + : Colors.green.shade700, + emphasize: true, + ), + ], + ), + ), + ); + } +} + +class _SummaryRow extends StatelessWidget { + const _SummaryRow({ + required this.icon, + required this.label, + required this.valueText, + required this.valueColor, + this.subtitle, + this.emphasize = false, + }); + + final IconData icon; + final String label; + final String valueText; + final Color valueColor; + final String? subtitle; + + /// Hebt Label + Wert hervor (für den „Offener Betrag"-Abschluss). + final bool emphasize; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 2), + child: Icon(icon, color: theme.colorScheme.primary), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: emphasize + ? const TextStyle(fontWeight: FontWeight.w700) + : null, + ), + if (subtitle != null) + Text( + subtitle!, + style: TextStyle( + fontSize: 12, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + Text( + valueText, + style: (emphasize + ? theme.textTheme.titleLarge + : theme.textTheme.titleMedium) + ?.copyWith( + fontWeight: FontWeight.w700, + color: valueColor, + ), + ), + ], + ); + } +} + +class _PaymentMethodPicker extends StatelessWidget { + const _PaymentMethodPicker({ + required this.delivery, + required this.overrideId, + }); + + final Delivery delivery; + final String? overrideId; + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + if (state is PaymentMethodsLoading || state is PaymentMethodsInitial) { + return const Card( + margin: EdgeInsets.zero, + child: Padding( + padding: EdgeInsets.all(16), + child: Row( + children: [ + SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ), + SizedBox(width: 12), + Text('Zahlungsmethoden laden …'), + ], + ), + ), + ); + } + if (state is PaymentMethodsFailed) { + return Card( + margin: EdgeInsets.zero, + color: Theme.of(context).colorScheme.errorContainer, + child: Padding( + padding: const EdgeInsets.all(16), + child: Text( + state.message, + style: TextStyle( + color: Theme.of(context).colorScheme.onErrorContainer, + ), + ), + ), + ); + } + final loaded = state as PaymentMethodsLoaded; + // Ausschließlich die Backend-Methoden — keine frontend-seitige + // Fabrikation/Hardcodierung. Es werden genau die angezeigt, die im + // Backend (Postgres `payment_methods`, aktiv) hinterlegt sind. + final methods = loaded.methods; + final selectedId = overrideId ?? delivery.paymentMethodId; + // Als Dropdown-Value nur setzen, wenn die Methode tatsächlich in der + // Backend-Liste ist (sonst würde Flutter asserten). Ist die zugewiesene + // Methode zwischenzeitlich deaktiviert/entfernt, bleibt das Feld leer. + final selectedValue = + methods.any((m) => m.id == selectedId) ? selectedId : null; + // Zahlungsmethode nur bei aktiver Lieferung änderbar. Bei + // abgeschlossener/abgebrochener/pausierter Lieferung zeigt das + // Dropdown den gewählten Stand, ist aber gesperrt. + final active = delivery.state == DeliveryState.active; + return Card( + margin: EdgeInsets.zero, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + DropdownButtonFormField( + initialValue: selectedValue, + decoration: const InputDecoration( + labelText: 'Zahlungsmethode', + border: OutlineInputBorder(), + ), + items: [ + for (final m in methods) + DropdownMenuItem( + value: m.id, + child: Text(m.name), + ), + ], + // `null` deaktiviert das Dropdown (Flutter-Konvention). + onChanged: active + ? (newId) { + if (newId == null) return; + context.read().add( + WorkflowOverridePaymentMethod( + // Zurück auf die Original-Methode → Override + // löschen, damit das Domain-Modell "no + // override" kennt. + paymentMethodId: + newId == delivery.paymentMethodId + ? null + : newId, + ), + ); + } + : null, + ), + if (!active) ...[ + const SizedBox(height: 8), + Row( + children: [ + Icon(Icons.lock_outline, + size: 16, + color: Theme.of(context).colorScheme.onSurfaceVariant), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Lieferung abgeschlossen — Zahlungsmethode nicht ' + 'mehr änderbar.', + style: Theme.of(context) + .textTheme + .bodySmall + ?.copyWith( + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, + ), + ), + ), + ], + ), + ], + ], + ), + ), + ); + }, + ); + } +} + +class _SignHint extends StatelessWidget { + const _SignHint(); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: theme.colorScheme.primary.withValues(alpha: 0.07), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: theme.colorScheme.primary.withValues(alpha: 0.3), + ), + ), + child: Row( + children: [ + Icon(Icons.draw_outlined, color: theme.colorScheme.primary), + const SizedBox(width: 10), + Expanded( + child: Text( + 'Mit „Unterschreiben" unten schließt der Kunde den Vorgang ab.', + style: TextStyle( + fontSize: 13, + color: theme.colorScheme.primary, + ), + ), + ), + ], + ), + ); } } diff --git a/lib/feature/delivery/detail/presentation/widget/discount_editor.dart b/lib/feature/delivery/detail/presentation/widget/discount_editor.dart new file mode 100644 index 0000000..54ccb5f --- /dev/null +++ b/lib/feature/delivery/detail/presentation/widget/discount_editor.dart @@ -0,0 +1,355 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'package:hl_lieferservice/feature/car_selection/bloc/bloc.dart'; +import 'package:hl_lieferservice/feature/car_selection/bloc/state.dart'; +import 'package:hl_lieferservice/feature/feature_flags.dart'; +import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.dart'; +import 'package:hl_lieferservice/feature/delivery/bloc/tour_event.dart'; +import 'package:hl_lieferservice/feature/delivery/bloc/tour_state.dart'; + +/// Gutschriften-Editor: ±10 €, max 150 €, Begründung Pflicht. +/// +/// Backend-gestützt: „Speichern" feuert `SetDeliveryCredit`, „Entfernen" +/// `RemoveDeliveryCredit` am `TourBloc` → `POST /deliveries/{id}/credit` +/// (append-only, idempotent). Der aktuelle Stand kommt aus dem Tour-Aggregat +/// (`TourDetails.creditOf`). +class DiscountEditor extends StatefulWidget { + const DiscountEditor({ + super.key, + required this.deliveryId, + required this.active, + }); + + final String deliveryId; + + /// Nur bei aktiver Lieferung darf die Gutschrift geändert werden. Bei + /// abgeschlossener/abgebrochener/pausierter Lieferung bleibt der Editor + /// sichtbar, aber gesperrt (reine Anzeige des gespeicherten Stands). + final bool active; + + @override + State createState() => _DiscountEditorState(); +} + +class _DiscountEditorState extends State { + static const int step = 10; // €-Schrittweite (nur die Stepper-Variante) + static const int max = 150; // € Obergrenze + static const int maxCents = max * 100; + + /// Betrag in Cent — erlaubt Dezimalbeträge (z. B. 19,99 € = 1999). + int _amountCents = 0; + late final TextEditingController _reasonController; + late final TextEditingController _amountController; + + @override + void initState() { + super.initState(); + _reasonController = TextEditingController(); + + // Einmalige Übernahme des aktuellen Server-Stands aus dem Bloc — VOR dem + // Anhängen des Listeners, damit das Setzen des Textes kein `setState` + // (über den Listener) während des ersten Builds auslöst. + final state = context.read().state; + if (state is TourLoaded) { + final current = state.details.creditOf(widget.deliveryId); + if (current != null) { + _amountCents = current.amountCents; + _reasonController.text = current.reason; + } + } + + // Freitext-Betragsfeld (Dezimal, € mit Cent): vorbelegt; Listener parst die + // Eingabe in `_amountCents`. Erst NACH dem Vorbelegen anhängen. + _amountController = TextEditingController(text: _formatCents(_amountCents)); + _amountController.addListener(() { + setState(() => _amountCents = _parseCents(_amountController.text) ?? 0); + }); + + _reasonController.addListener(() => setState(() {})); + } + + @override + void dispose() { + _reasonController.dispose(); + _amountController.dispose(); + super.dispose(); + } + + /// "19,99" / "19.99" / "20" → Cent. `null` bei leer/ungültig. + static int? _parseCents(String raw) { + final t = raw.trim().replaceAll(',', '.'); + if (t.isEmpty) return null; + final euros = double.tryParse(t); + if (euros == null) return null; + return (euros * 100).round(); + } + + /// Cent → Anzeige-String in € (mit Komma). 0 → leer. + static String _formatCents(int cents) { + if (cents <= 0) return ''; + if (cents % 100 == 0) return '${cents ~/ 100}'; + return (cents / 100).toStringAsFixed(2).replaceAll('.', ','); + } + + bool get _canDecrement => widget.active && _amountCents > 0; + bool get _canIncrement => + widget.active && _amountCents + step * 100 <= maxCents; + bool get _isReasonValid => _reasonController.text.trim().isNotEmpty; + + /// Backend-Regel: >0, ≤150 €. (Beliebige Beträge inkl. Cent.) + bool get _amountValid => _amountCents > 0 && _amountCents <= maxCents; + bool get _canSave => widget.active && _amountValid && _isReasonValid; + + // Stepper-Variante (Feature-Flag): bewegt sich in 10-€-Schritten. Das + // Textfeld ist dann nicht sichtbar, daher kein Controller-Sync nötig. + void _decrement() { + if (!_canDecrement) return; + setState(() => _amountCents = (_amountCents - step * 100).clamp(0, maxCents)); + } + + void _increment() { + if (!_canIncrement) return; + setState(() => _amountCents = (_amountCents + step * 100).clamp(0, maxCents)); + } + + String _actorCarId(BuildContext context) { + final state = context.read().state; + if (state is CarSelectComplete) return state.selectedCar.id; + return '00000000-0000-0000-0000-000000000000'; + } + + void _save() { + context.read().add(SetDeliveryCredit( + deliveryId: widget.deliveryId, + amountCents: _amountCents, + reason: _reasonController.text.trim(), + actorCarId: _actorCarId(context), + )); + } + + void _remove() { + context.read().add(RemoveDeliveryCredit( + deliveryId: widget.deliveryId, + actorCarId: _actorCarId(context), + )); + setState(() { + _amountCents = 0; + _reasonController.clear(); + _amountController.clear(); + }); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return BlocBuilder( + buildWhen: (a, b) { + if (a is! TourLoaded || b is! TourLoaded) return true; + return a.details.creditOf(widget.deliveryId) != + b.details.creditOf(widget.deliveryId); + }, + builder: (context, state) { + final current = state is TourLoaded + ? state.details.creditOf(widget.deliveryId) + : null; + final isSaved = current != null; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Betrag', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 8), + // Default: freies Betrags-Textfeld. Hinter dem Feature-Flag + // `discountAmountStepper` liegt die ursprüngliche +/−-Variante. + if (FeatureFlags.discountAmountStepper) + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + IconButton.filled( + onPressed: _canDecrement ? _decrement : null, + icon: const Icon(Icons.remove), + style: ButtonStyle( + backgroundColor: WidgetStatePropertyAll( + _canDecrement ? Colors.red.shade400 : Colors.grey, + ), + ), + ), + const SizedBox(width: 16), + Column( + children: [ + Text( + '${_amountCents ~/ 100} €', + style: const TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + ), + ), + Text( + 'max. $max € · Schritt $step €', + style: TextStyle( + fontSize: 11, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + const SizedBox(width: 16), + IconButton.filled( + onPressed: _canIncrement ? _increment : null, + icon: const Icon(Icons.add), + style: ButtonStyle( + backgroundColor: WidgetStatePropertyAll( + _canIncrement ? Colors.green.shade600 : Colors.grey, + ), + ), + ), + ], + ) + else + TextField( + controller: _amountController, + enabled: widget.active, + keyboardType: + const TextInputType.numberWithOptions(decimal: true), + decoration: InputDecoration( + border: const OutlineInputBorder(), + isDense: true, + prefixText: '€ ', + hintText: '0,00', + helperText: 'max. $max € · Cent erlaubt (z. B. 19,99)', + // Fehlertext nur bei nicht-leerer, ungültiger Eingabe. + errorText: (widget.active && + _amountController.text.trim().isNotEmpty && + !_amountValid) + ? 'Betrag muss > 0 und ≤ $max € sein' + : null, + ), + ), + const SizedBox(height: 16), + Text( + 'Begründung (Pflicht)', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 6), + TextField( + controller: _reasonController, + enabled: widget.active, + minLines: 2, + maxLines: 4, + decoration: const InputDecoration( + border: OutlineInputBorder(), + hintText: 'z. B. Transportschaden, Verzögerung …', + ), + ), + const SizedBox(height: 12), + if (!widget.active) ...[ + _LockedHint( + text: isSaved + ? 'Lieferung abgeschlossen — Gutschrift nicht mehr änderbar.' + : 'Gutschrift nur bei aktiver Lieferung änderbar.', + ), + const SizedBox(height: 12), + ], + if (isSaved && widget.active) ...[ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: Colors.green.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.check_circle_outline, + size: 14, + color: Colors.green.shade700, + ), + const SizedBox(width: 4), + Text( + 'Gespeichert', + style: TextStyle( + fontSize: 12, + color: Colors.green.shade800, + ), + ), + ], + ), + ), + const SizedBox(height: 8), + ], + // Buttons in einem Wrap: brechen auf schmalen Cards um, statt + // (wie zuvor in einer Row mit Spacer) rechts überzulaufen. Volle + // Breite, damit WrapAlignment.end rechtsbündig wirkt. + SizedBox( + width: double.infinity, + child: Wrap( + alignment: WrapAlignment.end, + spacing: 8, + runSpacing: 8, + children: [ + if (isSaved) + TextButton.icon( + onPressed: widget.active ? _remove : null, + icon: const Icon(Icons.delete_outline), + label: const Text('Entfernen'), + ), + FilledButton.icon( + onPressed: _canSave ? _save : null, + icon: const Icon(Icons.save), + label: Text(isSaved ? 'Aktualisieren' : 'Speichern'), + ), + ], + ), + ), + ], + ); + }, + ); + } +} + +/// Kleiner Hinweis-Balken, wenn eine Aktion gesperrt ist (Lieferung nicht +/// aktiv). Bewusst dezent — der Editor bleibt als Anzeige sichtbar. +class _LockedHint extends StatelessWidget { + const _LockedHint({required this.text}); + final String text; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Icon(Icons.lock_outline, + size: 16, color: theme.colorScheme.onSurfaceVariant), + const SizedBox(width: 8), + Expanded( + child: Text( + text, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/feature/delivery/detail/repository/delivery_repository.dart b/lib/feature/delivery/detail/repository/delivery_repository.dart deleted file mode 100644 index ac8130a..0000000 --- a/lib/feature/delivery/detail/repository/delivery_repository.dart +++ /dev/null @@ -1,57 +0,0 @@ -import 'dart:typed_data'; - -import 'package:hl_lieferservice/dto/discount_add_response.dart'; -import 'package:hl_lieferservice/dto/discount_remove_response.dart'; -import 'package:hl_lieferservice/dto/discount_update_response.dart'; -import 'package:hl_lieferservice/feature/delivery/detail/repository/note_repository.dart'; -import 'package:hl_lieferservice/feature/delivery/detail/service/notes_service.dart'; -import 'package:hl_lieferservice/feature/delivery/service/tour_service.dart'; -import 'package:hl_lieferservice/model/delivery.dart'; - -class DeliveryRepository { - DeliveryRepository({required this.service}); - - TourService service; - - Future unscan(String articleId, int newAmount, String reason) async { - return await service.unscanArticle(articleId, newAmount, reason); - } - - Future resetScan(String articleId) async { - return await service.resetScannedArticleAmount(articleId); - } - - Future uploadDriverSignature(String deliveryId, Uint8List signature) async { - NoteRepository noteRepository = NoteRepository(service: NoteService()); - await noteRepository.addNamedImage(deliveryId, signature, "delivery_${deliveryId}_signature_driver.jpg"); - } - - Future uploadCustomerSignature(String deliveryId, Uint8List signature) async { - NoteRepository noteRepository = NoteRepository(service: NoteService()); - await noteRepository.addNamedImage(deliveryId, signature, "delivery_${deliveryId}_signature_customer.jpg"); - } - - Future addDiscount( - String deliveryId, - String reason, - int value, - ) { - return service.addDiscount(deliveryId, value, reason); - } - - Future removeDiscount(String deliveryId) { - return service.removeDiscount(deliveryId); - } - - Future updateDiscount( - String deliveryId, - String? reason, - int? value, - ) { - return service.updateDiscount(deliveryId, reason, value); - } - - Future updateDelivery(Delivery delivery) { - return service.updateDelivery(delivery); - } -} diff --git a/lib/feature/delivery/detail/repository/note_repository.dart b/lib/feature/delivery/detail/repository/note_repository.dart deleted file mode 100644 index 5361bd4..0000000 --- a/lib/feature/delivery/detail/repository/note_repository.dart +++ /dev/null @@ -1,96 +0,0 @@ -import 'dart:typed_data'; - -import 'package:hl_lieferservice/model/delivery.dart'; -import 'package:hl_lieferservice/feature/delivery/detail/service/notes_service.dart'; -import 'package:rxdart/rxdart.dart'; - -class NoteRepository { - final NoteService service; - - final _notesStream = BehaviorSubject?>.seeded(null); - final _imageNoteStream = BehaviorSubject?>.seeded(null); - final _noteTemplateStream = BehaviorSubject?>.seeded(null); - - Stream?> get notes => _notesStream.stream; - Stream?> get images => _imageNoteStream.stream; - Stream?> get templates => _noteTemplateStream.stream; - - List get _currentNotes => _notesStream.value ?? []; - List get _currentImages => _imageNoteStream.value ?? []; - - NoteRepository({required this.service}); - - Future addNote(String deliveryId, String content) async { - final note = await service.addNote(content, int.parse(deliveryId)); - _currentNotes.add(note); - - _notesStream.add(_currentNotes); - } - - Future editNote(String noteId, String content) async { - final newNote = Note(content: content, id: int.parse(noteId)); - await service.editNote(newNote); - - final currentNotes = _notesStream.value; - final index = _currentNotes.indexWhere((note) => note.id == int.parse(noteId)); - - if (index != -1) { - _currentNotes[index] = newNote; - _notesStream.add(currentNotes); - } - } - - Future deleteNote(String noteId) async { - await service.deleteNote(int.parse(noteId)); - final currentNotes = _notesStream.value; - final index = _currentNotes.indexWhere((note) => note.id == int.parse(noteId)); - _currentNotes.removeAt(index); - - _notesStream.add(currentNotes); - } - - Future loadNotes(String deliveryId) async { - var (notes, images) = await service.getNotes(deliveryId); - - _notesStream.add(notes); - _imageNoteStream.add(images); - } - - Future loadTemplates() async { - _noteTemplateStream.add(await service.getNoteTemplates()); - } - - Future addImage(String deliveryId, Uint8List bytes) async { - final fileName = - "delivery_note_${deliveryId}_${DateTime.timestamp().microsecondsSinceEpoch}.jpg"; - - String objectId = await service.uploadImage( - deliveryId, - fileName, - bytes, - "image/png", - ); - _currentImages.add(ImageNote.make(objectId, fileName, bytes)); - _imageNoteStream.add(_currentImages); - } - - Future addNamedImage(String deliveryId, Uint8List bytes, String filename) async { - String objectId = await service.uploadImage( - deliveryId, - filename, - bytes, - "image/png", - ); - - _currentImages.add(ImageNote.make(objectId, filename, bytes)); - _imageNoteStream.add(_currentImages); - } - - Future deleteImage(String deliveryId, String objectId) async { - await service.removeImage(objectId); - - final index = _currentImages.indexWhere((imageNote) => imageNote.objectId == objectId); - _currentImages.removeAt(index); - _imageNoteStream.add(_currentImages); - } -} diff --git a/lib/feature/delivery/detail/service/notes_service.dart b/lib/feature/delivery/detail/service/notes_service.dart deleted file mode 100644 index 20ef152..0000000 --- a/lib/feature/delivery/detail/service/notes_service.dart +++ /dev/null @@ -1,327 +0,0 @@ -import 'dart:collection'; -import 'dart:convert'; -import 'dart:io'; -import 'dart:typed_data'; - -import 'package:hl_lieferservice/dto/note_get_response.dart'; -import 'package:hl_lieferservice/feature/delivery/detail/exceptions.dart'; -import 'package:hl_lieferservice/services/erpframe.dart'; -import 'package:flutter/cupertino.dart'; -import 'package:http/http.dart' as http; -import 'package:http_parser/http_parser.dart'; - -import '../../../../dto/basic_response.dart'; -import '../../../../dto/note_add_response.dart'; -import '../../../../dto/note_template_response.dart'; -import '../../../../model/delivery.dart'; -import '../../../../util.dart'; -import '../../../authentication/exceptions.dart'; - -class NoteService { - Future deleteNote(int noteId) async { - try { - var response = await http.post( - urlBuilder("_web_deleteNote"), - headers: getSessionOrThrow(), - body: {"id": noteId.toString()}, - ); - - if (response.statusCode == HttpStatus.unauthorized) { - throw UserUnauthorized(); - } - - Map responseJson = jsonDecode(response.body); - debugPrint("NOTE DELETE: ${response.body}"); - BasicResponseDTO responseDto = BasicResponseDTO.fromJson(responseJson); - - if (responseDto.succeeded == true) { - return; - } else { - throw responseDto.message; - } - } catch (e, st) { - debugPrint("ERROR WHILE DELETING NOTE $noteId"); - debugPrint("$e"); - debugPrint(st.toString()); - - rethrow; - } - } - - Future editNote(Note newNote) async { - try { - var response = await http.post( - urlBuilder("_web_editNote"), - headers: getSessionOrThrow(), - body: {"id": newNote.id.toString(), "note": newNote.content}, - ); - - if (response.statusCode == HttpStatus.unauthorized) { - throw UserUnauthorized(); - } - - Map responseJson = jsonDecode(response.body); - BasicResponseDTO responseDto = BasicResponseDTO.fromJson(responseJson); - - if (responseDto.succeeded == true) { - return; - } else { - throw responseDto.message; - } - } catch (e, st) { - debugPrint("ERROR WHILE EDITING NOTE ${newNote.id}"); - debugPrint("$e"); - debugPrint(st.toString()); - - rethrow; - } - } - - Future> getNoteTemplates() async { - try { - var response = await http.post( - urlBuilder("_web_getNoteTemplates"), - headers: getSessionOrThrow(), - body: {}, - ); - - if (response.statusCode == HttpStatus.unauthorized) { - throw UserUnauthorized(); - } - - Map responseJson = jsonDecode(response.body); - NoteTemplateResponseDTO responseDto = NoteTemplateResponseDTO.fromJson( - responseJson, - ); - - if (responseDto.succeeded == true) { - return responseDto.notes.map(NoteTemplate.fromDTO).toList(); - } else { - throw responseDto.message; - } - } catch (e, st) { - debugPrint("ERROR WHILE GETTING NOTE TEMPLATES"); - debugPrint("$e"); - debugPrint(st.toString()); - - rethrow; - } - } - - Future<(List, List)> getNotes(String deliveryId) async { - try { - var response = await http.post( - urlBuilder("_web_getNotes"), - headers: getSessionOrThrow(), - body: {"delivery_id": deliveryId}, - ); - - if (response.statusCode == HttpStatus.unauthorized) { - throw UserUnauthorized(); - } - - Map responseJson = jsonDecode(response.body); - NoteGetResponseDTO responseDto = NoteGetResponseDTO.fromJson( - responseJson, - ); - - if (responseDto.succeeded == true) { - List imageNotes = - responseDto.images - .map((imageNoteDto) => ImageNote.fromDTO(imageNoteDto)) - .toList(); - - final images = await downloadImages(imageNotes.map((note) => note.url).toList()); - for (var (index, note) in imageNotes.indexed) { - note.data = await images[index]; - } - - return ( - responseDto.notes - .map((noteDto) => Note.fromDto(noteDto)) - .toList(), - imageNotes - ); - } else { - throw responseDto.message; - } - } catch (e, st) { - debugPrint("ERROR WHILE GETTING NOTES"); - debugPrint("$e"); - debugPrint(st.toString()); - - rethrow; - } - } - - Future addNote(String note, int deliveryId) async { - try { - var response = await http.post( - urlBuilder("_web_addNote"), - headers: getSessionOrThrow(), - body: {"receipt_id": deliveryId.toString(), "note": note}, - ); - - if (response.statusCode == HttpStatus.unauthorized) { - throw UserUnauthorized(); - } - - Map responseJson = jsonDecode(response.body); - debugPrint(responseJson.toString()); - NoteAddResponseDTO responseDto = NoteAddResponseDTO.fromJson( - responseJson, - ); - - if (responseDto.succeeded == true) { - return Note.fromDto(responseDto.note!); - } else { - debugPrint("ERROR: ${responseDto.message}"); - throw responseDto.message; - } - } catch (e) { - rethrow; - } - } - - Future uploadImage( - String deliveryId, - String filename, - Uint8List bytes, - String? mimeType, - ) async { - try { - var config = getConfig(); - var basePath = "${config.backendUrl}/v1/uploadFile"; - var response = await http.get( - Uri.parse(basePath), - headers: getSessionOrThrow(), - ); - - if (response.statusCode == HttpStatus.unauthorized) { - throw UserUnauthorized(); - } - - Map jsonResponse = jsonDecode(response.body); - debugPrint("GET UPLOADID : ${response.body}"); - - if (!jsonResponse.containsKey("data")) { - debugPrint("No data structure in uploadFile request"); - debugPrint("RAW RESPONSE: ${response.body}"); - throw NoteImageAddException(); - } - - Map data = jsonResponse["data"]; - - if (!data.containsKey("uploadId")) { - debugPrint("No data.uploadId structure in uploadFile request"); - debugPrint("RAW RESPONSE: ${response.body}"); - throw NoteImageAddException(); - } - - String uploadId = data["uploadId"]; - http.MultipartRequest request = http.MultipartRequest( - "POST", - Uri.parse("$basePath/$uploadId"), - ); - - HashMap header = HashMap(); - header["Content-Type"] = "multipart/form-data"; - header.addAll(getSessionOrThrow()); - - request.headers.addAll(header); - request.files.add( - http.MultipartFile.fromBytes( - "file", - bytes, - filename: filename, - contentType: MediaType.parse(mimeType ?? "application/octet-stream"), - ), - ); - - http.Response fileUploadResponse = await http.Response.fromStream( - await request.send(), - ); - Map fileUploadResponseJson = jsonDecode( - fileUploadResponse.body, - ); - - debugPrint("UPLOAD IMAGE RESPONSE: ${fileUploadResponse.body}"); - - if (fileUploadResponseJson["status"]["internalStatus"] != "0") { - debugPrint("Failed to upload image"); - debugPrint("RAW: ${fileUploadResponseJson.toString()}"); - throw NoteImageAddException(); - } - - var fileCommitResponse = await http.patch( - Uri.parse("$basePath/$uploadId"), - headers: getSessionOrThrow(), - ); - debugPrint("FILE COMMIT BODY: ${fileCommitResponse.body}"); - var fileCommitResponseJson = jsonDecode(fileCommitResponse.body); - - return fileCommitResponseJson["data"]["~ObjectID"]; - } catch (e, st) { - debugPrint("An error occured:"); - debugPrint("$e"); - debugPrint(st.toString()); - - rethrow; - } - } - - Future>> downloadImages(List urls) async { - try { - LocalDocuFrameConfiguration config = getConfig(); - - return urls.map((url) async { - final response = await http.get( - Uri.parse("${config.backendUrl}$url"), - headers: getSessionOrThrow(), - ); - if (response.statusCode == HttpStatus.unauthorized) { - throw UserUnauthorized(); - } - return response.bodyBytes; - }).toList(); - } catch (e, st) { - debugPrint("An error occured:"); - debugPrint("$e"); - debugPrint(st.toString()); - - rethrow; - } - } - - Future removeImage(String oid) async { - try { - var response = await http.post( - urlBuilder("_web_removeImage"), - headers: getSessionOrThrow(), - body: {"oid": oid}, - ); - - if (response.statusCode == HttpStatus.unauthorized) { - throw UserUnauthorized(); - } - - Map responseJson = jsonDecode(response.body); - debugPrint(oid); - debugPrint(responseJson.toString()); - - BasicResponseDTO responseDto = BasicResponseDTO.fromJson(responseJson); - - if (responseDto.succeeded == true) { - return; - } else { - debugPrint("ERROR: ${responseDto.message}"); - throw responseDto.message; - } - } catch (e, st) { - debugPrint("$e"); - debugPrint(st.toString()); - - rethrow; - } - } -} diff --git a/lib/feature/delivery/overview/model/sorting_information.dart b/lib/feature/delivery/overview/model/sorting_information.dart deleted file mode 100644 index 6029b9e..0000000 --- a/lib/feature/delivery/overview/model/sorting_information.dart +++ /dev/null @@ -1,56 +0,0 @@ -import 'package:flutter/cupertino.dart'; - -class SortingInformation { - String deliveryId; - int position; - - SortingInformation({required this.deliveryId, required this.position}); - - static Map toJson(SortingInformation info) { - return {"delivery_id": info.deliveryId, "position": info.position}; - } - - static SortingInformation fromJson(Map json) { - return SortingInformation( - deliveryId: json["delivery_id"].toString(), - position: json["position"], - ); - } -} - -class SortingInformationContainer { - Map> cars; - - SortingInformationContainer({required this.cars}); - - static SortingInformationContainer fromJson(Map json) { - SortingInformationContainer container = SortingInformationContainer( - cars: {}, - ); - - for (final car in json["cars"].entries) { - List values = []; - for (String value in car.value) { - values.add(value); - } - - container.cars[car.key] = values; - } - - return container; - } - - Map toJson() { - Map cars = {}; - - for (final car in this.cars.entries) { - cars[car.key] = car.value; - } - - return {"cars": cars}; - } - - SortingInformationContainer copyWith({Map>? sorting}) { - return SortingInformationContainer(cars: sorting ?? cars); - } -} diff --git a/lib/feature/delivery/overview/presentation/delivery_fail_page.dart b/lib/feature/delivery/overview/presentation/delivery_fail_page.dart index fe8dae7..9ff7857 100644 --- a/lib/feature/delivery/overview/presentation/delivery_fail_page.dart +++ b/lib/feature/delivery/overview/presentation/delivery_fail_page.dart @@ -1,16 +1,17 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:hl_lieferservice/feature/authentication/bloc/auth_bloc.dart'; -import 'package:hl_lieferservice/feature/authentication/bloc/auth_state.dart'; import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.dart'; import 'package:hl_lieferservice/feature/delivery/bloc/tour_event.dart'; +/// Fallback-Page, die der Übersichts- und Beladen-Pfad anzeigt, wenn der +/// Initial-Tour-Load gescheitert ist. Tap auf "Erneut versuchen" feuert +/// `LoadTour` erneut — Account-Filter sitzt jetzt im JWT, daher keine +/// Personalnummer mehr nötig. class DeliveryLoadingFailedPage extends StatelessWidget { const DeliveryLoadingFailedPage({super.key}); void _onRetry(BuildContext context) { - Authenticated state = context.read().state as Authenticated; - context.read().add(LoadTour(teamId: state.user.number)); + context.read().add(const LoadTour()); } @override @@ -21,18 +22,22 @@ class DeliveryLoadingFailedPage extends StatelessWidget { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(Icons.error_outline, size: 72, color: Theme.of(context).colorScheme.error,), - Padding( - padding: const EdgeInsets.only(top: 30), + Icon( + Icons.error_outline, + size: 72, + color: Theme.of(context).colorScheme.error, + ), + const Padding( + padding: EdgeInsets.only(top: 30), child: Text( - "Leider ist es beim Laden der Fahrten zu einem Fehler gekommen.", + 'Leider ist es beim Laden der Fahrten zu einem Fehler gekommen.', ), ), Padding( padding: const EdgeInsets.only(top: 30), child: FilledButton( onPressed: () => _onRetry(context), - child: Text("Erneut versuchen"), + child: const Text('Erneut versuchen'), ), ), ], diff --git a/lib/feature/delivery/overview/presentation/delivery_info.dart b/lib/feature/delivery/overview/presentation/delivery_info.dart index 0c7a38c..8704d5a 100644 --- a/lib/feature/delivery/overview/presentation/delivery_info.dart +++ b/lib/feature/delivery/overview/presentation/delivery_info.dart @@ -1,23 +1,28 @@ import 'package:flutter/material.dart'; -import 'package:hl_lieferservice/model/delivery.dart' show DeliveryState; -import 'package:hl_lieferservice/model/tour.dart'; +import 'package:hl_lieferservice/domain/entity/delivery.dart'; +import 'package:hl_lieferservice/domain/entity/tour_details.dart'; import 'package:intl/intl.dart'; +/// Kopf-Karte der Auslieferungs-Übersicht. Zeigt Datum, Anzahl Lieferungen +/// und Fortschrittsbalken — gefiltert auf das aktuell gewählte Fahrzeug, +/// damit der Fahrer seine eigene Tagesleistung sieht. class DeliveryInfo extends StatelessWidget { - final Tour tour; + final TourDetails details; final String? selectedCarId; - const DeliveryInfo({super.key, required this.tour, this.selectedCarId}); + const DeliveryInfo({super.key, required this.details, this.selectedCarId}); @override Widget build(BuildContext context) { - final String date = DateFormat("dd.MM.yyyy").format(tour.date); + final date = DateFormat('dd.MM.yyyy').format(details.tour.date); final relevantDeliveries = selectedCarId != null - ? tour.deliveries.where((d) => d.carId == selectedCarId).toList() - : tour.deliveries; + ? details.deliveries + .where((d) => d.assignedCarId == selectedCarId) + .toList() + : details.deliveries; final total = relevantDeliveries.length; final done = relevantDeliveries - .where((d) => d.state == DeliveryState.finished) + .where((d) => d.state == DeliveryState.completed) .length; final progress = total > 0 ? done / total : 0.0; final allDone = total > 0 && done == total; @@ -37,11 +42,11 @@ class DeliveryInfo extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Row( - children: [ - const Icon(Icons.calendar_month), - const Padding( + children: const [ + Icon(Icons.calendar_month), + Padding( padding: EdgeInsets.only(left: 5), - child: Text("Datum"), + child: Text('Datum'), ), ], ), @@ -53,15 +58,15 @@ class DeliveryInfo extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Row( - children: [ - const Icon(Icons.local_shipping_outlined), - const Padding( + children: const [ + Icon(Icons.local_shipping_outlined), + Padding( padding: EdgeInsets.only(left: 5), - child: Text("Lieferungen"), + child: Text('Lieferungen'), ), ], ), - Text("$done / $total"), + Text('$done / $total'), ], ), const SizedBox(height: 10), diff --git a/lib/feature/delivery/overview/presentation/delivery_item.dart b/lib/feature/delivery/overview/presentation/delivery_item.dart deleted file mode 100644 index c1a9dff..0000000 --- a/lib/feature/delivery/overview/presentation/delivery_item.dart +++ /dev/null @@ -1,153 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:hl_lieferservice/feature/authentication/bloc/auth_bloc.dart'; -import 'package:hl_lieferservice/model/delivery.dart'; -import 'package:hl_lieferservice/feature/delivery/detail/presentation/delivery_detail_page.dart'; - -import '../../../../widget/operations/bloc/operation_bloc.dart'; -import '../../detail/bloc/note_bloc.dart'; -import '../../detail/repository/note_repository.dart'; -import '../../detail/service/notes_service.dart'; - -class DeliveryListItem extends StatelessWidget { - final Delivery delivery; - final double? distance; - - const DeliveryListItem({ - super.key, - required this.delivery, - this.distance, - }); - - void _goToDelivery(BuildContext context) { - Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => BlocProvider( - create: (context) => NoteBloc( - deliveryId: delivery.id, - opBloc: context.read(), - authBloc: context.read(), - repository: NoteRepository(service: NoteService()), - ), - child: DeliveryDetail(deliveryId: delivery.id), - ), - ), - ); - } - - (Color, Color, IconData, String) _stateStyle(BuildContext context) { - switch (delivery.state) { - case DeliveryState.finished: - return ( - Colors.green.withValues(alpha: 0.07), - Colors.green.withValues(alpha: 0.35), - Icons.check_circle_rounded, - "Abgeschlossen", - ); - case DeliveryState.canceled: - return ( - Colors.red.withValues(alpha: 0.07), - Colors.red.withValues(alpha: 0.35), - Icons.cancel_rounded, - "Storniert", - ); - case DeliveryState.onhold: - return ( - Colors.orange.withValues(alpha: 0.07), - Colors.orange.withValues(alpha: 0.35), - Icons.pause_circle_rounded, - "Pausiert", - ); - case DeliveryState.ongoing: - final distanceLabel = distance != null && !distance!.isNaN - ? "${distance!.toStringAsFixed(1)} km" - : "–"; - return ( - Theme.of(context).colorScheme.surfaceContainerLow, - Colors.transparent, - Icons.local_shipping_outlined, - distanceLabel, - ); - } - } - - @override - Widget build(BuildContext context) { - final (cardColor, borderColor, icon, statusLabel) = _stateStyle(context); - final isOngoing = delivery.state == DeliveryState.ongoing; - - final iconColor = switch (delivery.state) { - DeliveryState.finished => Colors.green, - DeliveryState.canceled => Colors.red, - DeliveryState.onhold => Colors.orange, - DeliveryState.ongoing => Theme.of(context).primaryColor, - }; - - return Card( - margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), - elevation: 0, - color: cardColor, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - side: BorderSide(color: borderColor), - ), - child: InkWell( - borderRadius: BorderRadius.circular(12), - onTap: () => _goToDelivery(context), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - child: Row( - children: [ - Icon(icon, color: iconColor, size: 28), - const SizedBox(width: 14), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - delivery.customer.name, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - color: isOngoing ? null : iconColor, - ), - ), - const SizedBox(height: 2), - Text( - delivery.customer.address.toString(), - style: TextStyle( - fontSize: 13, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), - const SizedBox(width: 8), - Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text( - statusLabel, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w500, - color: isOngoing - ? Theme.of(context).colorScheme.onSurfaceVariant - : iconColor, - ), - ), - const SizedBox(height: 4), - Icon( - Icons.arrow_forward_ios, - size: 14, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ], - ), - ], - ), - ), - ), - ); - } -} diff --git a/lib/feature/delivery/overview/presentation/delivery_list.dart b/lib/feature/delivery/overview/presentation/delivery_list.dart deleted file mode 100644 index 142d355..0000000 --- a/lib/feature/delivery/overview/presentation/delivery_list.dart +++ /dev/null @@ -1,126 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.dart'; -import 'package:hl_lieferservice/feature/delivery/bloc/tour_state.dart'; - -import 'package:hl_lieferservice/feature/delivery/overview/presentation/delivery_overview.dart'; -import 'package:hl_lieferservice/model/delivery.dart'; - -import 'delivery_item.dart'; - -class DeliveryList extends StatefulWidget { - final String? selectedCarId; - final SortType sortType; - - const DeliveryList({super.key, this.selectedCarId, required this.sortType}); - - @override - State createState() => _DeliveryListState(); -} - -class _DeliveryListState extends State { - @override - void initState() { - super.initState(); - } - - Widget _showCustomSortedList( - List deliveries, - List sortingInformation, - Map distances, - ) { - return ListView.separated( - shrinkWrap: true, - physics: NeverScrollableScrollPhysics(), - separatorBuilder: (context, index) => const Divider(height: 0), - itemBuilder: (context, index) { - String id = sortingInformation[index]; - Delivery delivery = deliveries.firstWhere( - (delivery) => - id == delivery.id && - delivery.carId == widget.selectedCarId, - ); - - return DeliveryListItem( - delivery: delivery, - distance: distances[delivery.id], - ); - }, - itemCount: sortingInformation.length, - ); - } - - @override - Widget build(BuildContext context) { - return BlocBuilder( - builder: (context, state) { - final currentState = state; - if (currentState is TourLoaded) { - if (widget.sortType == SortType.custom) { - return _showCustomSortedList( - currentState.tour.deliveries, - currentState.sortingInformation[widget.selectedCarId.toString()] ?? [], - currentState.distances ?? {}, - ); - } - - final allDeliveries = currentState.tour.deliveries - .where((d) => d.carId == widget.selectedCarId) - .toList(); - - if (allDeliveries.isEmpty) { - return ListView( - physics: const NeverScrollableScrollPhysics(), - shrinkWrap: true, - children: const [ - Center(child: Text("Keine Auslieferungen gefunden")), - ], - ); - } - - final ongoing = allDeliveries - .where((d) => d.state == DeliveryState.ongoing) - .toList(); - final nonOngoing = allDeliveries - .where((d) => d.state != DeliveryState.ongoing) - .toList(); - - int Function(Delivery, Delivery) comparator; - switch (widget.sortType) { - case SortType.nameAsc: - comparator = (a, b) => a.customer.name.compareTo(b.customer.name); - break; - case SortType.nameDesc: - comparator = (a, b) => b.customer.name.compareTo(a.customer.name); - break; - case SortType.distance: - comparator = (a, b) => - (currentState.distances?[a.id] ?? 0.0) - .compareTo(currentState.distances?[b.id] ?? 0.0); - break; - default: - comparator = (a, b) => a.customer.name.compareTo(b.customer.name); - } - - ongoing.sort(comparator); - nonOngoing.sort(comparator); - - final sorted = [...ongoing, ...nonOngoing]; - - return ListView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - padding: const EdgeInsets.only(bottom: 8), - itemCount: sorted.length, - itemBuilder: (context, index) => DeliveryListItem( - delivery: sorted[index], - distance: currentState.distances?[sorted[index].id], - ), - ); - } - - return Center(child: CircularProgressIndicator()); - }, - ); - } -} diff --git a/lib/feature/delivery/overview/presentation/delivery_overview.dart b/lib/feature/delivery/overview/presentation/delivery_overview.dart index 0eb19c5..e5feca2 100644 --- a/lib/feature/delivery/overview/presentation/delivery_overview.dart +++ b/lib/feature/delivery/overview/presentation/delivery_overview.dart @@ -1,58 +1,85 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:hl_lieferservice/domain/entity/article.dart'; +import 'package:hl_lieferservice/domain/entity/customer.dart'; +import 'package:hl_lieferservice/domain/entity/delivery.dart'; +import 'package:hl_lieferservice/domain/entity/delivery_item.dart'; +import 'package:hl_lieferservice/domain/entity/tour_details.dart'; +import 'package:hl_lieferservice/domain/entity/warehouse.dart'; import 'package:hl_lieferservice/feature/car_selection/bloc/bloc.dart'; import 'package:hl_lieferservice/feature/car_selection/bloc/state.dart'; -import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.dart'; -import 'package:hl_lieferservice/feature/delivery/bloc/tour_event.dart'; +import 'package:hl_lieferservice/feature/delivery/detail/presentation/delivery_detail_page.dart'; import 'package:hl_lieferservice/feature/delivery/overview/presentation/delivery_info.dart'; -import 'package:hl_lieferservice/feature/delivery/overview/presentation/delivery_list.dart'; -import 'package:hl_lieferservice/feature/delivery/overview/presentation/delivery_overview_custom_sort.dart'; -import 'package:hl_lieferservice/model/tour.dart'; +import 'package:hl_lieferservice/feature/delivery/pickup/presentation/filiale_pickup_scan_page.dart'; -import '../../../authentication/bloc/auth_bloc.dart'; -import '../../../authentication/bloc/auth_state.dart'; - -enum SortType { nameAsc, nameDesc, distance, custom } +/// Entscheidet beim Tap auf eine Lieferung, wohin navigiert wird: +/// +/// * Aktive Lieferung mit noch offenen Filial-Artikeln → zuerst der +/// Filial-Abhol-Scan-Screen. Der Fahrer ist an der Filiale und muss die +/// Ware abscannen, bevor er ausliefern kann. Nach dem Scan kehrt er zur +/// Übersicht zurück (die Lieferung verliert ihren Filial-Hinweis). +/// * Sonst → direkt die Auslieferung (`DeliveryDetail`), die beim Kunden +/// bearbeitet wird. +/// +/// Damit „schaltet" dieselbe Lieferung zustandsabhängig um, ohne dass es +/// dafür einen eigenen Status braucht. +void _openDelivery( + BuildContext context, + Delivery delivery, + TourDetails details, +) { + final needsPickup = delivery.state == DeliveryState.active && + details.hasPendingExternalWarehouseItems(delivery); + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => needsPickup + ? FilialePickupScanPage(deliveryId: delivery.id) + : DeliveryDetail(deliveryId: delivery.id), + ), + ); +} +/// Sektionen-Übersicht der Auslieferungs-Phase. +/// +/// Architektur-Schwester von `LoadingOverviewPage`: gleiche Sektions-Logik, +/// gleicher Tile-Stil, aber andere Bucket-Semantik — wir sortieren nicht +/// nach Beladestand, sondern nach Lebenszyklus der Lieferung: +/// +/// * Eine **prominente Karte** für die nächste anstehende Lieferung +/// (großer Kundenname, Adresse, Uhrzeit, Sonderwünsche). Wenn dort noch +/// Filial-Artikel offen sind, sagt sie dem Fahrer Klartext, dass er +/// zuerst das Lager ansteuern muss — inkl. Artikel-Auflistung. +/// * Sektionen darunter: `Offen`, `Pausiert`, `Fertig`, `Abgebrochen`. +/// +/// `assignedCarId` wird zur Filterung herangezogen — Multi-Car-Teams sollen +/// nicht die Lieferungen der Kollegen sehen. class DeliveryOverview extends StatefulWidget { - const DeliveryOverview({ - super.key, - required this.tour, - }); + const DeliveryOverview({super.key, required this.details}); - final Tour tour; + final TourDetails details; @override - State createState() => _DeliveryOverviewState(); + State createState() => _DeliveryOverviewState(); } class _DeliveryOverviewState extends State { String? _selectedCarId; - late SortType _sortType; @override void initState() { super.initState(); - - // Pre-select today's car from the daily car selection. - // Falls back to the first available car if no selection exists. final carSelectState = context.read().state; if (carSelectState is CarSelectComplete) { _selectedCarId = carSelectState.selectedCar.id; } else { - _selectedCarId = widget.tour.driver.cars.firstOrNull?.id; + // Falls keine Car-Auswahl getroffen ist (z. B. Single-Car-Team ohne + // expliziten Wechsel), das erstbeste zugewiesene Auto übernehmen. + final assigned = widget.details.deliveries + .map((d) => d.assignedCarId) + .whereType() + .toList(); + _selectedCarId = assigned.isNotEmpty ? assigned.first : null; } - _sortType = SortType.nameAsc; - } - - Future _loadTour() async { - Authenticated state = context.read().state as Authenticated; - context.read().add(LoadTour(teamId: state.user.number)); - } - - /// Highlight the text of the active sorting type. - TextStyle? _popupItemTextStyle() { - return TextStyle(color: Theme.of(context).primaryColor, fontWeight: FontWeight.bold); } @override @@ -63,105 +90,926 @@ class _DeliveryOverviewState extends State { setState(() => _selectedCarId = carState.selectedCar.id); } }, - child: RefreshIndicator( - onRefresh: _loadTour, - child: ListView( - //crossAxisAlignment: CrossAxisAlignment.start, + child: _DeliveryOverviewBody( + details: widget.details, + selectedCarId: _selectedCarId, + ), + ); + } +} + +class _DeliveryOverviewBody extends StatelessWidget { + const _DeliveryOverviewBody({ + required this.details, + required this.selectedCarId, + }); + + final TourDetails details; + final String? selectedCarId; + + /// Sortiert nach `sortOrder` aufsteigend, weil das die vom Fahrer im + /// Sortieren-Schritt festgelegte Reihenfolge ist und identisch zur + /// Auslieferungs-Reihenfolge. + List _ownDeliveriesInDeliveryOrder() { + final all = details.deliveriesSorted; + if (selectedCarId == null) return all; + return all.where((d) => d.assignedCarId == selectedCarId).toList(); + } + + @override + Widget build(BuildContext context) { + final deliveries = _ownDeliveriesInDeliveryOrder(); + // Untere System-Navigationsleiste (Home/Zurück/Recent) freihalten, damit + // das letzte Listenelement nicht dahinter rutscht. Es gibt in dieser + // Phase keinen Bottom-Bar, der den Inset abdecken würde. + final bottomInset = MediaQuery.viewPaddingOf(context).bottom; + + if (deliveries.isEmpty) { + return ListView( + padding: EdgeInsets.only(bottom: bottomInset), children: [ - DeliveryInfo(tour: widget.tour, selectedCarId: _selectedCarId), - Padding( - padding: const EdgeInsets.only( - left: 10, - right: 10, - top: 0, - bottom: 10, + DeliveryInfo(details: details, selectedCarId: selectedCarId), + const SizedBox(height: 48), + const Center( + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 32), + child: Text( + 'Keine Auslieferungen für dieses Fahrzeug.', + textAlign: TextAlign.center, + ), + ), + ), + ], + ); + } + + // Lieferungen in Buckets aufteilen. Position-Nr. bezieht sich auf die + // Auslieferungs-Reihenfolge (`sortOrder`) — bleibt sichtbar, auch wenn + // die Karte in „Pausiert" / „Fertig" einsortiert wird. + final active = <_DeliveryEntry>[]; + final paused = <_DeliveryEntry>[]; + final done = <_DeliveryEntry>[]; + final canceled = <_DeliveryEntry>[]; + for (int i = 0; i < deliveries.length; i++) { + final entry = _DeliveryEntry(position: i + 1, delivery: deliveries[i]); + switch (entry.delivery.state) { + case DeliveryState.active: + active.add(entry); + case DeliveryState.held: + paused.add(entry); + case DeliveryState.completed: + done.add(entry); + case DeliveryState.canceled: + canceled.add(entry); + } + } + + // Erste aktive Lieferung ist die „Nächste" — wird aus der Offen-Liste + // herausgezogen und in eigener prominenter Karte gezeigt. Beim nächsten + // Build (nach Status-Wechsel) rutscht automatisch die nächste nach. + final _DeliveryEntry? nextUp = active.isEmpty ? null : active.removeAt(0); + + // Wenn unter der „Nächste Lieferung"-Karte sonst nichts käme (typisch: + // es gibt nur diese eine Lieferung), einen dezenten Platzhalter zeigen, + // damit der Bereich nicht leer wirkt. + final nothingElseBelow = active.isEmpty && + paused.isEmpty && + done.isEmpty && + canceled.isEmpty; + + return ListView( + padding: EdgeInsets.only(bottom: 24 + bottomInset), + children: [ + DeliveryInfo(details: details, selectedCarId: selectedCarId), + if (nextUp != null) + _NextDeliverySection(entry: nextUp, details: details), + if (nextUp != null && nothingElseBelow) const _NoFurtherDeliveriesHint(), + if (active.isNotEmpty) + _BucketSection( + title: 'Offen', + count: active.length, + color: Theme.of(context) + .colorScheme + .primary + .withValues(alpha: 0.55), + entries: active, + details: details, + ), + if (paused.isNotEmpty) + _BucketSection( + title: 'Pausiert', + count: paused.length, + color: Colors.orange.shade800, + icon: Icons.pause_circle_outline, + entries: paused, + details: details, + ), + if (done.isNotEmpty) + _BucketSection( + title: 'Fertig', + count: done.length, + color: Colors.green.shade700, + icon: Icons.check_circle_outline, + entries: done, + details: details, + ), + if (canceled.isNotEmpty) + _BucketSection( + title: 'Abgebrochen', + count: canceled.length, + color: Colors.red.shade700, + icon: Icons.cancel_outlined, + entries: canceled, + details: details, + ), + ], + ); + } +} + +/// Lieferung + Position innerhalb der Auslieferungs-Reihenfolge. Position +/// überlebt das Aufsplitten in Buckets — der Fahrer sieht im Tile immer +/// „seine" Nummer, egal in welcher Sektion. +class _DeliveryEntry { + const _DeliveryEntry({required this.position, required this.delivery}); + final int position; + final Delivery delivery; +} + +/// Dezenter Platzhalter unter der „Nächste Lieferung"-Karte, wenn es keine +/// weiteren Lieferungen gibt (z. B. nur eine Lieferung insgesamt) — damit der +/// Bereich nicht leer wirkt. Bekommt dieselbe „Offen"-Zwischenüberschrift wie +/// die anderen Sektionen (farbiger Balken + Titel + Zähler). +class _NoFurtherDeliveriesHint extends StatelessWidget { + const _NoFurtherDeliveriesHint(); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final headerColor = theme.colorScheme.primary.withValues(alpha: 0.55); + final muted = theme.colorScheme.onSurfaceVariant; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Header im selben Stil wie _BucketSection (Balken + Titel + Pill). + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 4), + child: Row( + children: [ + Container( + width: 4, + height: 18, + decoration: BoxDecoration( + color: headerColor, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: 8), + Text( + 'Offen', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: headerColor, + letterSpacing: 0.4, + ), + ), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 1), + decoration: BoxDecoration( + color: headerColor.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + '0', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: headerColor, + ), + ), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 4), + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest + .withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(12), ), child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Row( - children: [ - Text( - "Fahrten", - style: Theme.of(context).textTheme.headlineSmall, - ), - ], - ), - PopupMenuButton( - onSelected: (SortType value) { - switch (value) { - case SortType.nameAsc: - setState(() { - _sortType = SortType.nameAsc; - }); - - break; - case SortType.nameDesc: - setState(() { - _sortType = SortType.nameDesc; - }); - break; - case SortType.distance: - setState(() { - _sortType = SortType.distance; - }); - break; - case SortType.custom: - setState(() { - _sortType = SortType.custom; - }); - - showDialog( - context: context, - fullscreenDialog: true, - builder: (context) => CustomSortDialog(selectedCarId: _selectedCarId,), - ); - break; - } - }, - itemBuilder: - (BuildContext context) => >[ - PopupMenuItem( - value: SortType.nameAsc, - child: Text( - 'Name (A-Z)', - style: _sortType == SortType.nameAsc ? _popupItemTextStyle() : null, - ), - ), - PopupMenuItem( - value: SortType.nameDesc, - child: Text( - 'Name (Z-A)', - style: _sortType == SortType.nameDesc ? _popupItemTextStyle() : null, - ), - ), - PopupMenuItem( - value: SortType.distance, - child: Text( - 'Entfernung', - style: _sortType == SortType.distance ? _popupItemTextStyle() : null, - ), - ), - PopupMenuItem( - value: SortType.custom, - child: Text( - 'Eigene Sortierung', - style: _sortType == SortType.custom ? _popupItemTextStyle() : null, - ), - ), - ], - child: Icon(Icons.filter_list), + Icon(Icons.inbox_outlined, size: 18, color: muted), + const SizedBox(width: 10), + Expanded( + child: Text( + 'Keine weiteren offenen Lieferungen.', + style: theme.textTheme.bodyMedium?.copyWith(color: muted), + ), ), ], ), ), - DeliveryList( - selectedCarId: _selectedCarId, - sortType: _sortType, - ), - ], - ), - ), + ), + ], + ); + } +} + +// ─── „Nächste Lieferung" prominent ────────────────────────────────────── + +/// Sektions-Header für „Nächste Lieferung" + die große Karte darunter. Der +/// Header läuft im Primary-Akzent, damit das Auge sofort dort landet. +class _NextDeliverySection extends StatelessWidget { + const _NextDeliverySection({required this.entry, required this.details}); + + final _DeliveryEntry entry; + final TourDetails details; + + @override + Widget build(BuildContext context) { + final color = Theme.of(context).colorScheme.primary; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 4), + child: Row( + children: [ + Container( + width: 4, + height: 18, + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: 8), + Icon(Icons.local_shipping_outlined, size: 16, color: color), + const SizedBox(width: 6), + Text( + 'Nächste Lieferung', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: color, + letterSpacing: 0.4, + ), + ), + ], + ), + ), + _NextDeliveryCard(entry: entry, details: details), + ], + ); + } +} + +/// Große Highlight-Karte für die nächste anstehende Lieferung. Layout-Ziel: +/// alle „Wer / Wohin / Wann"-Infos auf einen Blick, ohne dass der Fahrer +/// in die Detail-Page muss. Pflicht-Hinweis bei offenen Filial-Items, +/// damit er nicht losfährt, ohne vorher das Lager anzusteuern. +class _NextDeliveryCard extends StatelessWidget { + const _NextDeliveryCard({required this.entry, required this.details}); + + final _DeliveryEntry entry; + final TourDetails details; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final delivery = entry.delivery; + final customer = details.customerOf(delivery); + final pendingExternal = details.pendingExternalWarehouseGroups(delivery); + final hasPendingExternal = pendingExternal.isNotEmpty; + + return Card( + margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + elevation: 1, + color: theme.colorScheme.primaryContainer.withValues(alpha: 0.55), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: BorderSide( + color: theme.colorScheme.primary.withValues(alpha: 0.45), + width: 1.2, + ), + ), + child: InkWell( + borderRadius: BorderRadius.circular(14), + onTap: () => _openDelivery(context, delivery, details), + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _HeaderRow( + position: entry.position, + customer: customer, + desiredTime: delivery.desiredTime, + ), + const SizedBox(height: 10), + _InfoRow( + icon: Icons.location_on_outlined, + text: delivery.deliveryAddressSnapshot.oneLine, + emphasized: true, + ), + if (delivery.specialAgreements != null && + delivery.specialAgreements!.isNotEmpty) ...[ + const SizedBox(height: 6), + _InfoRow( + icon: Icons.sticky_note_2_outlined, + text: delivery.specialAgreements!, + ), + ], + if (hasPendingExternal) ...[ + const SizedBox(height: 12), + _PendingExternalBanner( + groups: pendingExternal, + articleLookup: details.articleOf, + ), + ], + if (delivery.prepaidAmount > 0) ...[ + const SizedBox(height: 8), + _InfoRow( + icon: Icons.payments_outlined, + text: + 'Anzahlung: ${delivery.prepaidAmount.toStringAsFixed(2)} €', + ), + ], + const SizedBox(height: 8), + Align( + alignment: Alignment.centerRight, + child: TextButton.icon( + onPressed: () => _openDelivery(context, delivery, details), + icon: Icon( + hasPendingExternal + ? Icons.qr_code_scanner + : Icons.arrow_forward, + ), + label: Text( + hasPendingExternal + ? 'Artikel aus Filiale scannen' + : 'Details öffnen', + ), + ), + ), + ], + ), + ), + ), + ); + } +} + +class _HeaderRow extends StatelessWidget { + const _HeaderRow({ + required this.position, + required this.customer, + required this.desiredTime, + }); + + final int position; + final Customer? customer; + final String? desiredTime; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CircleAvatar( + backgroundColor: theme.colorScheme.primary, + foregroundColor: theme.colorScheme.onPrimary, + radius: 20, + child: Text( + '$position', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + customer?.name ?? '⟨Unbekannter Kunde⟩', + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + if (desiredTime != null && desiredTime!.isNotEmpty) ...[ + const SizedBox(height: 4), + Row( + children: [ + Icon( + Icons.access_time, + size: 16, + color: theme.colorScheme.primary, + ), + const SizedBox(width: 4), + Text( + desiredTime!, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + color: theme.colorScheme.primary, + ), + ), + ], + ), + ], + ], + ), + ), + ], + ); + } +} + +class _InfoRow extends StatelessWidget { + const _InfoRow({ + required this.icon, + required this.text, + this.emphasized = false, + }); + + final IconData icon; + final String text; + final bool emphasized; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 2), + child: Icon( + icon, + size: 16, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(width: 6), + Expanded( + child: Text( + text, + style: TextStyle( + fontSize: emphasized ? 14 : 13, + fontWeight: emphasized ? FontWeight.w600 : FontWeight.w500, + color: theme.colorScheme.onSurface, + ), + ), + ), + ], + ); + } +} + +/// Roter Banner direkt auf der „Nächste Lieferung"-Karte. Listet die noch +/// nicht beladenen Filial-Artikel auf, damit der Fahrer sofort sieht, +/// dass er erst ins Lager muss — *und* welche Artikel ihn dort erwarten. +/// Bewusst kein dezenter Hinweis: das ist die wichtigste Information auf +/// dem Bildschirm, sobald sie zutrifft. +class _PendingExternalBanner extends StatelessWidget { + const _PendingExternalBanner({ + required this.groups, + required this.articleLookup, + }); + + /// Filiale + offene Items pro Lager. + final List<({Warehouse warehouse, List items})> groups; + + final Article? Function(String articleId) articleLookup; + + String _itemLabel(DeliveryItem item) { + final article = articleLookup(item.articleId); + final name = article?.name ?? '⟨Unbekannter Artikel⟩'; + final number = article?.articleNumber ?? ''; + final qty = item.requiredQuantity; + if (number.isEmpty) return '$qty × $name'; + return '$qty × $name ($number)'; + } + + @override + Widget build(BuildContext context) { + final color = Colors.amber.shade800; + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.amber.withValues(alpha: 0.18), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: Colors.amber.withValues(alpha: 0.7)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.warehouse_outlined, size: 18, color: color), + const SizedBox(width: 6), + Expanded( + child: Text( + 'Erst Artikel aus der Filiale holen!', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + color: color, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + for (final group in groups) ...[ + Text( + group.warehouse.name, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + color: color, + letterSpacing: 0.3, + ), + ), + const SizedBox(height: 2), + for (final item in group.items) + Padding( + padding: const EdgeInsets.only(left: 4, bottom: 2), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '• ', + style: TextStyle(color: color, fontSize: 13), + ), + Expanded( + child: Text( + _itemLabel(item), + style: TextStyle( + fontSize: 13, + color: Colors.brown.shade900, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 4), + ], + ], + ), + ); + } +} + +// ─── Standard-Sektion + Tile ──────────────────────────────────────────── + +/// Generische Bucket-Sektion mit Farb-Akzent, Pill-Counter und Tiles. +/// Visuelle Sprache identisch zur Beladen-Übersicht, damit der Fahrer +/// keine zwei UI-Paradigmen lernen muss. +class _BucketSection extends StatelessWidget { + const _BucketSection({ + required this.title, + required this.count, + required this.color, + required this.entries, + required this.details, + this.icon, + }); + + final String title; + final int count; + final Color color; + final List<_DeliveryEntry> entries; + final TourDetails details; + final IconData? icon; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 4), + child: Row( + children: [ + Container( + width: 4, + height: 18, + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: 8), + if (icon != null) ...[ + Icon(icon, size: 16, color: color), + const SizedBox(width: 6), + ], + Text( + title, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: color, + letterSpacing: 0.4, + ), + ), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 1, + ), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + '$count', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: color, + ), + ), + ), + ], + ), + ), + for (final entry in entries) + _DeliveryTile(entry: entry, details: details), + ], + ); + } +} + +/// Kompakter Tile in den Sektionen unterhalb der „Nächste Lieferung"- +/// Karte. Zeigt Position, Kundenname, Adresse, Uhrzeit (falls vorhanden), +/// Status — und ein Filial-Badge, wenn dort noch Artikel offen sind. +class _DeliveryTile extends StatelessWidget { + const _DeliveryTile({required this.entry, required this.details}); + + final _DeliveryEntry entry; + final TourDetails details; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final delivery = entry.delivery; + final customer = details.customerOf(delivery); + final pendingExternal = details.pendingExternalWarehouseGroups(delivery); + final hasPendingExternal = pendingExternal.isNotEmpty; + + final style = _TileStyle.forState(theme, delivery.state); + + return Opacity( + opacity: delivery.state == DeliveryState.canceled ? 0.65 : 1.0, + child: Card( + margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + elevation: 0, + color: style.cardColor, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide(color: style.borderColor), + ), + child: InkWell( + borderRadius: BorderRadius.circular(12), + onTap: () => _openDelivery(context, delivery, details), + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + CircleAvatar( + backgroundColor: style.avatarColor, + foregroundColor: theme.colorScheme.onPrimary, + radius: 18, + child: Text( + '${entry.position}', + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + customer?.name ?? '⟨Unbekannter Kunde⟩', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: style.titleColor, + decoration: delivery.state == DeliveryState.canceled + ? TextDecoration.lineThrough + : TextDecoration.none, + ), + ), + const SizedBox(height: 2), + Text( + delivery.deliveryAddressSnapshot.oneLine, + style: TextStyle( + fontSize: 12, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 4), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 2), + child: Icon( + style.statusIcon, + size: 14, + color: style.titleColor, + ), + ), + const SizedBox(width: 4), + Expanded( + child: Text( + style.statusLabel, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: style.titleColor, + ), + ), + ), + if (delivery.desiredTime != null && + delivery.desiredTime!.isNotEmpty) ...[ + const SizedBox(width: 8), + Icon( + Icons.access_time, + size: 12, + color: theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 2), + Text( + delivery.desiredTime!, + style: TextStyle( + fontSize: 12, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ], + ), + if (delivery.stateReason != null && + delivery.stateReason!.isNotEmpty && + (delivery.state == DeliveryState.held || + delivery.state == DeliveryState.canceled)) + Padding( + padding: const EdgeInsets.only(left: 18, top: 2), + child: Text( + 'Grund: ${delivery.stateReason!}', + style: TextStyle( + fontSize: 11, + color: style.titleColor, + ), + ), + ), + // Filial-Hinweis nur bei aktiven Lieferungen mit + // offenen Items — pausiert/abgebrochen sind ohnehin + // nicht in Arbeit, und „fertig" hat trivialerweise + // keine offenen Items mehr. + if (delivery.state == DeliveryState.active && + hasPendingExternal) ...[ + const SizedBox(height: 6), + _PendingExternalBadge( + warehouses: pendingExternal + .map((g) => g.warehouse.name) + .toList(growable: false), + ), + ], + ], + ), + ), + const Icon(Icons.chevron_right), + ], + ), + ), + ), + ), + ); + } +} + +/// Visuelle Sprache je nach Lebenszyklus der Lieferung. Bewusst getrennt, +/// damit der Tile-Builder eine einzige `_TileStyle.forState(...)` baut und +/// nicht in jeder Zeile wieder ein `switch` braucht. +class _TileStyle { + const _TileStyle({ + required this.cardColor, + required this.borderColor, + required this.titleColor, + required this.avatarColor, + required this.statusLabel, + required this.statusIcon, + }); + + final Color cardColor; + final Color borderColor; + final Color titleColor; + final Color avatarColor; + final String statusLabel; + final IconData statusIcon; + + factory _TileStyle.forState(ThemeData theme, DeliveryState state) { + switch (state) { + case DeliveryState.active: + return _TileStyle( + cardColor: theme.colorScheme.surfaceContainerLow, + borderColor: Colors.transparent, + titleColor: theme.colorScheme.onSurface, + avatarColor: theme.colorScheme.primary, + statusLabel: 'Offen', + statusIcon: Icons.radio_button_unchecked, + ); + case DeliveryState.held: + return _TileStyle( + cardColor: Colors.orange.withValues(alpha: 0.07), + borderColor: Colors.orange.withValues(alpha: 0.45), + titleColor: Colors.orange.shade800, + avatarColor: Colors.orange.shade800, + statusLabel: 'Pausiert', + statusIcon: Icons.pause_circle_outline, + ); + case DeliveryState.completed: + return _TileStyle( + cardColor: Colors.green.withValues(alpha: 0.07), + borderColor: Colors.green.withValues(alpha: 0.35), + titleColor: Colors.green.shade700, + avatarColor: Colors.green.shade700, + statusLabel: 'Abgeschlossen', + statusIcon: Icons.check_circle_outline, + ); + case DeliveryState.canceled: + return _TileStyle( + cardColor: Colors.red.withValues(alpha: 0.06), + borderColor: Colors.red.withValues(alpha: 0.35), + titleColor: Colors.red.shade700, + avatarColor: Colors.red.shade700, + statusLabel: 'Abgebrochen', + statusIcon: Icons.cancel_outlined, + ); + } + } +} + +/// Kompaktes Filial-Badge fürs Tile (Sektionen-Liste). Anders als das +/// `_PendingExternalBanner` auf der „Nächste Lieferung"-Karte zeigt es +/// nur die Lager-Namen — die Artikel-Detail-Auflistung würde den Tile +/// optisch sprengen. +class _PendingExternalBadge extends StatelessWidget { + const _PendingExternalBadge({required this.warehouses}); + + final List warehouses; + + @override + Widget build(BuildContext context) { + final color = Colors.amber.shade800; + final text = warehouses.isEmpty + ? 'Filial-Artikel offen' + : 'Erst aus Filiale holen: ${warehouses.join(", ")}'; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.amber.withValues(alpha: 0.22), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: Colors.amber.withValues(alpha: 0.7)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.warehouse_outlined, size: 14, color: color), + const SizedBox(width: 4), + Flexible( + child: Text( + text, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: color, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), ); } } diff --git a/lib/feature/delivery/overview/presentation/delivery_overview_custom_sort.dart b/lib/feature/delivery/overview/presentation/delivery_overview_custom_sort.dart deleted file mode 100644 index 5a28bb1..0000000 --- a/lib/feature/delivery/overview/presentation/delivery_overview_custom_sort.dart +++ /dev/null @@ -1,151 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.dart'; -import 'package:hl_lieferservice/feature/delivery/bloc/tour_event.dart'; -import 'package:hl_lieferservice/feature/delivery/bloc/tour_state.dart'; -import 'package:hl_lieferservice/feature/delivery/util.dart'; -import 'package:hl_lieferservice/model/delivery.dart'; - -class CustomSortDialog extends StatefulWidget { - const CustomSortDialog({super.key, this.selectedCarId}); - - final String? selectedCarId; - - @override - State createState() => _CustomSortDialogState(); -} - -class _CustomSortDialogState extends State { - late List _localSortedList; - - @override - void initState() { - super.initState(); - final state = context.read().state; - if (state is TourLoaded) { - _localSortedList = [ - ...state.sortingInformation[widget.selectedCarId.toString()] ?? [], - ]; - } else { - _localSortedList = []; - } - } - - Widget _information() { - return Padding( - padding: EdgeInsets.only(top: 15), - child: Column( - children: [ - Padding( - padding: EdgeInsets.all(15), - child: Row( - children: [ - Padding( - padding: EdgeInsets.only(right: 15), - child: Icon(Icons.info_outline, color: Colors.blueAccent), - ), - Expanded( - child: Text( - "Ziehen Sie die einzelnen Lieferungen mit dem Finger in die gewünschte Position.", - ), - ), - ], - ), - ), - Divider(), - _sortableList(), - ], - ), - ); - } - - Widget _sortableList() { - return BlocBuilder( - builder: (context, state) { - final currentState = state; - - if (currentState is TourLoaded) { - return Expanded( - child: ReorderableListView( - onReorder: (oldIndex, newIndex) { - setState(() { - _localSortedList = reorderList( - _localSortedList, - oldIndex, - newIndex, - ); - }); - - context.read().add( - ReorderDeliveryEvent( - newPosition: newIndex, - oldPosition: oldIndex, - carId: widget.selectedCarId.toString(), - ), - ); - }, - children: - _localSortedList - .map((id) { - Delivery delivery = currentState.tour.deliveries - .firstWhere((delivery) => delivery.id == id); - int pos = _localSortedList.indexOf(id) + 1; - - return ListTile( - leading: CircleAvatar( - backgroundColor: Theme.of(context).primaryColor, - child: Text( - "$pos", - style: TextStyle( - color: - Theme.of(context).colorScheme.onSecondary, - ), - ), - ), - title: Text(delivery.customer.name), - subtitle: Text( - delivery.customer.address.toString(), - style: TextStyle(fontSize: 11), - ), - trailing: Icon(Icons.drag_handle), - key: Key("reorder-item-${delivery.id}"), - ); - }) - .toList(), - ), - ); - } - - return Center(child: CircularProgressIndicator()); - }, - ); - } - - @override - Widget build(BuildContext context) { - return Dialog( - child: Column( - children: [ - Padding( - padding: const EdgeInsets.only(left: 20, right: 10, top: 15), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - "Fahrten sortieren", - style: Theme.of(context).textTheme.headlineSmall, - ), - IconButton( - onPressed: () => Navigator.of(context).pop(), - icon: const Icon(Icons.close), - ), - ], - ), - ), - - Expanded(child: _information()), - ], - ), - ); - } -} diff --git a/lib/feature/delivery/overview/presentation/delivery_overview_page.dart b/lib/feature/delivery/overview/presentation/delivery_overview_page.dart index 17c0618..7c55acd 100644 --- a/lib/feature/delivery/overview/presentation/delivery_overview_page.dart +++ b/lib/feature/delivery/overview/presentation/delivery_overview_page.dart @@ -1,69 +1,32 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:hl_lieferservice/domain/entity/tour_details.dart'; import 'package:hl_lieferservice/feature/car_selection/bloc/bloc.dart'; import 'package:hl_lieferservice/feature/car_selection/bloc/state.dart'; +import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.dart'; +import 'package:hl_lieferservice/feature/delivery/bloc/tour_event.dart'; +import 'package:hl_lieferservice/feature/delivery/bloc/tour_state.dart'; import 'package:hl_lieferservice/feature/delivery/model/delivery_phase.dart'; import 'package:hl_lieferservice/feature/delivery/overview/presentation/delivery_fail_page.dart'; import 'package:hl_lieferservice/feature/delivery/overview/presentation/delivery_overview.dart'; -import 'package:hl_lieferservice/model/tour.dart'; import 'package:hl_lieferservice/widget/home/presentation/home_drawer.dart'; import 'package:hl_lieferservice/widget/phase_stepper/phase_stepper.dart'; -import '../../bloc/tour_bloc.dart'; -import '../../bloc/tour_state.dart'; /// Inhalt der Phase "Ausliefern". Sortieren und Beladen werden über eigene /// Pages und das Phasen-Routing in `Home` gerendert — diese Page übernimmt /// nur noch die letzte Phase. Der Phasen-Stepper bleibt sichtbar, damit der /// Fahrer bei Bedarf zurückspringen kann; das BottomNav der Auslieferung /// liegt im umgebenden `Home`-Scaffold. -class DeliveryOverviewPage extends StatefulWidget { +class DeliveryOverviewPage extends StatelessWidget { const DeliveryOverviewPage({super.key}); - @override - State createState() => _DeliveryOverviewPageState(); -} - -class _DeliveryOverviewPageState extends State { - Widget _buildOverviewWithBanner({ - required Tour tour, - required String bannerText, - }) { - return Column( - children: [ - Material( - color: Colors.amber.shade100, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Row( - children: [ - const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ), - const SizedBox(width: 12), - Expanded(child: Text(bannerText)), - ], - ), - ), - ), - Expanded( - child: DeliveryOverview(tour: tour), - ), - ], - ); - } - @override Widget build(BuildContext context) { final carState = context.watch().state; - final carId = carState is CarSelectComplete - ? carState.selectedCar.id.toString() - : ""; + final carId = + carState is CarSelectComplete ? carState.selectedCar.id : ''; return Scaffold( - // Drawer ist hier ebenfalls aktiv, damit der Menü-Button des Steppers - // konsistent über alle Phasen funktioniert. drawer: const HomeAppDrawer(), appBar: PreferredSize( preferredSize: const Size.fromHeight(140), @@ -72,25 +35,79 @@ class _DeliveryOverviewPageState extends State { carId: carId, ), ), - body: BlocBuilder( + body: BlocConsumer( + listenWhen: (prev, next) => + next is TourLoaded && next.refreshError != null, + listener: (context, state) { + if (state is TourLoaded && state.refreshError != null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(state.refreshError!)), + ); + } + }, builder: (context, state) { - if (state is TourLoaded) { - if (state.distances == null) { - return _buildOverviewWithBanner( - tour: state.tour, - bannerText: "Berechne Distanzen…", - ); - } - return DeliveryOverview(tour: state.tour); + switch (state) { + case TourLoaded(:final details): + return _OverviewBody(details: details); + case TourEmpty(): + return const _EmptyTourBody(); + case TourLoadFailed(): + return const DeliveryLoadingFailedPage(); + case TourInitial(): + case TourLoading(): + return const Center(child: CircularProgressIndicator()); } - - if (state is TourLoadingFailed) { - return DeliveryLoadingFailedPage(); - } - - return const Center(child: CircularProgressIndicator()); }, ), ); } } + +class _OverviewBody extends StatelessWidget { + const _OverviewBody({required this.details}); + + final TourDetails details; + + @override + Widget build(BuildContext context) { + return RefreshIndicator( + onRefresh: () async { + context.read().add(const RefreshTour()); + }, + child: DeliveryOverview(details: details), + ); + } +} + +class _EmptyTourBody extends StatelessWidget { + const _EmptyTourBody(); + + @override + Widget build(BuildContext context) { + // Wenn der ERP-Sync für heute keine Tour gemeldet hat, ist das ein + // normaler Zustand — kein Fehler. UX-Hinweis und Pull-to-refresh. + return RefreshIndicator( + onRefresh: () async { + context.read().add(const RefreshTour()); + }, + child: ListView( + children: const [ + SizedBox(height: 120), + Icon(Icons.event_busy, size: 64, color: Colors.grey), + SizedBox(height: 16), + Center( + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 32), + child: Text( + 'Für heute ist keine Tour zugewiesen.\n' + 'Zum Aktualisieren nach unten ziehen.', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 16), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/feature/delivery/overview/presentation/delivery_selection_page.dart b/lib/feature/delivery/overview/presentation/delivery_selection_page.dart index 3342d08..9130790 100644 --- a/lib/feature/delivery/overview/presentation/delivery_selection_page.dart +++ b/lib/feature/delivery/overview/presentation/delivery_selection_page.dart @@ -1,6 +1,9 @@ -import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:hl_lieferservice/domain/entity/delivery.dart'; +import 'package:hl_lieferservice/domain/entity/tour_details.dart'; +import 'package:hl_lieferservice/feature/cars/bloc/cars_bloc.dart'; +import 'package:hl_lieferservice/feature/cars/bloc/cars_state.dart'; import 'package:hl_lieferservice/feature/delivery/bloc/phase_bloc.dart'; import 'package:hl_lieferservice/feature/delivery/bloc/phase_event.dart'; import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.dart'; @@ -8,8 +11,6 @@ import 'package:hl_lieferservice/feature/delivery/bloc/tour_event.dart'; import 'package:hl_lieferservice/feature/delivery/bloc/tour_state.dart'; import 'package:hl_lieferservice/feature/delivery/model/delivery_phase.dart'; import 'package:hl_lieferservice/feature/delivery/overview/presentation/delivery_fail_page.dart'; -import 'package:hl_lieferservice/model/delivery.dart'; -import 'package:hl_lieferservice/model/tour.dart'; import 'package:hl_lieferservice/widget/home/presentation/home_drawer.dart'; import 'package:hl_lieferservice/widget/phase_stepper/phase_stepper.dart'; @@ -20,18 +21,11 @@ import 'package:hl_lieferservice/widget/phase_stepper/phase_stepper.dart'; /// * Tab "Verfügbar" — alle Lieferungen, die noch keinem Fahrzeug /// zugeordnet sind. Multiselect, Bulk-Bestätigung per BottomBar-Button. /// * Tab "Vergeben" — Lieferungen, die bereits zugeordnet sind. Tap auf -/// eigene Lieferung → Freigabe-Dialog; Tap auf fremde → Umlade-Dialog. -/// * Persistent untere BottomBar: "Weiter zum Sortieren" — wechselt die -/// Phase. Es gibt bewusst keinen Zwang, dass alle Lieferungen verteilt -/// sein müssen; der Fahrer entscheidet wann er weiterzieht. +/// eigene → Freigabe-Dialog; Tap auf fremde → Umlade-Dialog. +/// * Persistente BottomBar: "Weiter zum Sortieren" — wechselt die Phase. class DeliverySelectionPage extends StatefulWidget { - const DeliverySelectionPage({ - super.key, - required this.selectedCarId, - }); + const DeliverySelectionPage({super.key, required this.selectedCarId}); - /// ID des aktuell gewählten Fahrzeugs (Eigene Lieferungen / Ziel von - /// Übernahmen). final String selectedCarId; @override @@ -39,86 +33,63 @@ class DeliverySelectionPage extends StatefulWidget { } class _DeliverySelectionPageState extends State { - /// Lokale Multi-Selektion im Tab "Verfügbar". Wird nach erfolgreichem - /// Bulk-Assign geleert. final Set _selectedIds = {}; - /// True während sequentieller Assign-Calls — schaltet den - /// Bestätigungs-Button auf Spinner und disabled. - bool _isAssigning = false; - - String get _carIdString => widget.selectedCarId.toString(); - - /// Sucht das Plate eines Autos in der Tour-Driver-Liste. Liefert "?" als - /// Fallback, falls die Zuordnung nicht (mehr) im Team enthalten ist — - /// z. B. nach Personalwechsel zwischen Tour-Synchronisationen. - String _plateFor(String? carId, Tour tour) { - if (carId == null) return "?"; - final car = tour.driver.cars.firstWhereOrNull((c) => c.id == carId); - return car?.plate ?? "?"; + /// Sucht das Kennzeichen eines Fahrzeugs in der aktuell geladenen + /// CarsBloc-Liste. Liefert "?" als Fallback, wenn das Auto nicht (mehr) + /// im Account ist — z. B. nach Personalwechsel zwischen Tour-Syncs. + String _plateFor(String? carId) { + if (carId == null) return '?'; + final carsState = context.read().state; + if (carsState is! CarsLoaded) return '?'; + for (final c in carsState.cars) { + if (c.id == carId) return c.plate; + } + return '?'; } - /// Bulk-Assign der aktuell selektierten Lieferungen an das eigene Auto. - /// Sequentiell, damit der lokale Tour-Stream nach jedem Schritt - /// konsistent ist und die Listen-Filter live mitwandern. Bei Fehlern - /// wird eine SnackBar gezeigt und die Selektion bleibt erhalten. - Future _confirmSelection() async { - if (_selectedIds.isEmpty || _isAssigning) return; - - setState(() => _isAssigning = true); - final tourBloc = context.read(); + void _confirmSelection() { + if (_selectedIds.isEmpty) return; final ids = List.from(_selectedIds); - try { - for (final id in ids) { - tourBloc.add( - AssignCarEvent(deliveryId: id, carId: _carIdString), - ); - } - // Hinweis: TourBloc verarbeitet die Events asynchron; lokale - // Tour-Updates erfolgen über den Stream. Wir leeren die Selektion - // optimistisch, damit der Fahrer ein klares Feedback bekommt. - if (!mounted) return; - setState(_selectedIds.clear); - } catch (e) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text("Fehler beim Übernehmen: $e")), - ); - } finally { - if (mounted) setState(() => _isAssigning = false); - } - } - - /// Wechselt in die nächste Phase (Sortieren). Der Fahrer kann jederzeit - /// zurückspringen — die persistierte Phase wird über den Stepper-Tap - /// zurückgesetzt. - void _goToSorting() { - context.read().add( - PhaseSet( - carId: _carIdString, - phase: DeliveryPhase.sortieren, + // EIN Bulk-Event statt N parallel laufender Single-Events: vermeidet + // die Race-Condition, bei der `flutter_bloc`s default-concurrent + // Event-Processing parallele Handler den Initial-State lesen lässt + // und sich am Ende beim `emit` gegenseitig überschreiben. + context.read().add( + AssignCarToDeliveries( + deliveryIds: ids, + carId: widget.selectedCarId, ), ); + + setState(_selectedIds.clear); } - Future _showReleaseDialog(Delivery delivery) async { + void _goToSorting() { + context.read().add( + PhaseSet(carId: widget.selectedCarId, phase: DeliveryPhase.sortieren), + ); + } + + Future _showReleaseDialog(Delivery delivery, TourDetails details) async { + final customer = details.customerOf(delivery); final result = await showDialog( context: context, builder: (ctx) => AlertDialog( - title: const Text("Lieferung freigeben"), + title: const Text('Lieferung freigeben'), content: Text( - "${delivery.customer.name} wurde Ihrem Fahrzeug zugeordnet. " - "Möchten Sie diese Lieferung wieder freigeben?", + '${customer?.name ?? 'Diese Lieferung'} wurde Ihrem Fahrzeug ' + 'zugeordnet. Möchten Sie sie wieder freigeben?', ), actions: [ TextButton( onPressed: () => Navigator.of(ctx).pop(false), - child: const Text("Abbrechen"), + child: const Text('Abbrechen'), ), FilledButton( onPressed: () => Navigator.of(ctx).pop(true), - child: const Text("Freigeben"), + child: const Text('Freigeben'), ), ], ), @@ -126,54 +97,57 @@ class _DeliverySelectionPageState extends State { if (result != true || !mounted) return; context.read().add( - UnassignDeliveryEvent(deliveryId: delivery.id), + AssignCarToDelivery(deliveryId: delivery.id, carId: null), ); } - Future _showTakeoverDialog(Delivery delivery, Tour tour) async { - final foreignPlate = _plateFor(delivery.carId, tour); - final ownPlate = _plateFor(widget.selectedCarId, tour); + Future _showTakeoverDialog( + Delivery delivery, + TourDetails details, + ) async { + final customer = details.customerOf(delivery); + final foreignPlate = _plateFor(delivery.assignedCarId); + final ownPlate = _plateFor(widget.selectedCarId); final theme = Theme.of(context); final result = await showDialog( context: context, builder: (ctx) => AlertDialog( - title: const Text("Lieferung umladen"), + title: const Text('Lieferung umladen'), content: RichText( text: TextSpan( style: theme.textTheme.bodyMedium, children: [ - TextSpan(text: "${delivery.customer.name} ist aktuell "), + TextSpan( + text: '${customer?.name ?? 'Diese Lieferung'} ist aktuell ', + ), TextSpan( text: foreignPlate, style: const TextStyle(fontWeight: FontWeight.bold), ), - const TextSpan(text: " zugeordnet. Möchten Sie diese " - "Lieferung auf "), + const TextSpan( + text: ' zugeordnet. Möchten Sie diese Lieferung auf ', + ), TextSpan( text: ownPlate, style: const TextStyle(fontWeight: FontWeight.bold), ), - const TextSpan(text: " umladen?"), + const TextSpan(text: ' umladen?'), ], ), ), actions: [ TextButton( onPressed: () => Navigator.of(ctx).pop(false), - child: const Text("Abbrechen"), + child: const Text('Abbrechen'), ), FilledButton( - // Warnfarbe, da das Umladen eine bestehende Zuordnung - // überschreibt — last write wins. `colorScheme.error` ist - // bewusst hart gewählt, damit der Fahrer den Eingriff in - // fremde Disposition bewusst bestätigt. style: FilledButton.styleFrom( backgroundColor: theme.colorScheme.error, foregroundColor: theme.colorScheme.onError, ), onPressed: () => Navigator.of(ctx).pop(true), - child: const Text("Übernehmen"), + child: const Text('Übernehmen'), ), ], ), @@ -181,16 +155,14 @@ class _DeliverySelectionPageState extends State { if (result != true || !mounted) return; context.read().add( - AssignCarEvent( + AssignCarToDelivery( deliveryId: delivery.id, - carId: _carIdString, + carId: widget.selectedCarId, ), ); } - // --------------------------------------------------------------------------- - // Widgets - // --------------------------------------------------------------------------- + // ─── Widgets ───────────────────────────────────────────────────────── Widget _plateBadge(BuildContext context, String plate, {bool own = false}) { final theme = Theme.of(context); @@ -213,11 +185,7 @@ class _DeliverySelectionPageState extends State { const SizedBox(width: 4), Text( plate, - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.bold, - color: fg, - ), + style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold, color: fg), ), ], ), @@ -257,13 +225,13 @@ class _DeliverySelectionPageState extends State { ); } - Widget _availableTab(List available) { + Widget _availableTab(List available, TourDetails details) { if (available.isEmpty) { return _emptyState( icon: Icons.inbox_outlined, - title: "Alle Lieferungen sind verteilt.", - subtitle: "Im Tab \"Vergeben\" können Sie eigene Lieferungen " - "freigeben oder fremde übernehmen.", + title: 'Alle Lieferungen sind verteilt.', + subtitle: 'Im Tab "Vergeben" können Sie eigene Lieferungen ' + 'freigeben oder fremde übernehmen.', ); } @@ -272,27 +240,29 @@ class _DeliverySelectionPageState extends State { itemCount: available.length, itemBuilder: (context, index) { final delivery = available[index]; + final customer = details.customerOf(delivery); final isSelected = _selectedIds.contains(delivery.id); return CheckboxListTile( - key: ValueKey("available-${delivery.id}"), + key: ValueKey('available-${delivery.id}'), value: isSelected, - onChanged: _isAssigning - ? null - : (checked) { - setState(() { - if (checked == true) { - _selectedIds.add(delivery.id); - } else { - _selectedIds.remove(delivery.id); - } - }); - }, + onChanged: (checked) { + // Während ein Bulk-Zuweisung läuft, blockiert der + // OperationViewEnforcer die Eingabe global; ein separates + // `_isAssigning`-Lock ist hier nicht mehr nötig. + setState(() { + if (checked == true) { + _selectedIds.add(delivery.id); + } else { + _selectedIds.remove(delivery.id); + } + }); + }, title: Text( - delivery.customer.name, + customer?.name ?? '⟨Unbekannter Kunde⟩', style: const TextStyle(fontWeight: FontWeight.w600), ), subtitle: Text( - delivery.customer.address.toString(), + delivery.deliveryAddressSnapshot.oneLine, style: const TextStyle(fontSize: 12), ), controlAffinity: ListTileControlAffinity.leading, @@ -301,11 +271,11 @@ class _DeliverySelectionPageState extends State { ); } - Widget _assignedTab(List assigned, Tour tour) { + Widget _assignedTab(List assigned, TourDetails details) { if (assigned.isEmpty) { return _emptyState( icon: Icons.local_shipping_outlined, - title: "Noch keine Lieferungen verteilt.", + title: 'Noch keine Lieferungen verteilt.', ); } @@ -316,20 +286,21 @@ class _DeliverySelectionPageState extends State { itemCount: assigned.length, itemBuilder: (context, index) { final delivery = assigned[index]; - final isOwn = delivery.carId == widget.selectedCarId; - final plate = _plateFor(delivery.carId, tour); + final isOwn = delivery.assignedCarId == widget.selectedCarId; + final plate = _plateFor(delivery.assignedCarId); + final customer = details.customerOf(delivery); return Material( color: isOwn ? theme.colorScheme.primaryContainer.withValues(alpha: 0.35) : null, child: ListTile( - key: ValueKey("assigned-${delivery.id}"), + key: ValueKey('assigned-${delivery.id}'), onTap: () { if (isOwn) { - _showReleaseDialog(delivery); + _showReleaseDialog(delivery, details); } else { - _showTakeoverDialog(delivery, tour); + _showTakeoverDialog(delivery, details); } }, leading: Icon( @@ -339,14 +310,14 @@ class _DeliverySelectionPageState extends State { : theme.colorScheme.onSurfaceVariant, ), title: Text( - delivery.customer.name, + customer?.name ?? '⟨Unbekannter Kunde⟩', style: TextStyle( fontWeight: FontWeight.w600, color: isOwn ? theme.colorScheme.primary : null, ), ), subtitle: Text( - delivery.customer.address.toString(), + delivery.deliveryAddressSnapshot.oneLine, style: const TextStyle(fontSize: 12), ), trailing: _plateBadge(context, plate, own: isOwn), @@ -356,13 +327,12 @@ class _DeliverySelectionPageState extends State { ); } - /// BottomBar mit Bulk-Confirm (kontextabhängig) + Phasen-Wechsel. - /// Confirm-Button ist nur sichtbar, wenn etwas selektiert ist — - /// "Weiter zum Sortieren" bleibt immer sichtbar. Widget _buildBottomBar() { - final theme = Theme.of(context); final hasSelection = _selectedIds.isNotEmpty; + // Disabled-State und Spinner während des Bulk-Zuweisens übernimmt + // global der `OperationViewEnforcer` (StartOperation/FinishOperation + // aus dem TourBloc) — daher hier keine lokale Lock-Variable mehr. return SafeArea( child: Padding( padding: const EdgeInsets.all(12), @@ -373,20 +343,10 @@ class _DeliverySelectionPageState extends State { SizedBox( width: double.infinity, child: FilledButton.icon( - onPressed: - _isAssigning ? null : _confirmSelection, - icon: _isAssigning - ? SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator( - strokeWidth: 2, - color: theme.colorScheme.onPrimary, - ), - ) - : const Icon(Icons.check), + onPressed: _confirmSelection, + icon: const Icon(Icons.check), label: Text( - "Auswahl bestätigen (${_selectedIds.length})", + 'Auswahl bestätigen (${_selectedIds.length})', ), ), ), @@ -394,9 +354,9 @@ class _DeliverySelectionPageState extends State { SizedBox( width: double.infinity, child: OutlinedButton.icon( - onPressed: _isAssigning ? null : _goToSorting, + onPressed: _goToSorting, icon: const Icon(Icons.arrow_forward), - label: const Text("Weiter zum Sortieren"), + label: const Text('Weiter zum Sortieren'), ), ), ], @@ -409,24 +369,38 @@ class _DeliverySelectionPageState extends State { Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { - if (state is TourLoadingFailed) { + if (state is TourLoadFailed) { return const DeliveryLoadingFailedPage(); } + if (state is TourEmpty) { + return Scaffold( + appBar: AppBar(title: const Text('Lieferungen auswählen')), + body: const Center( + child: Padding( + padding: EdgeInsets.all(24), + child: Text( + 'Für heute ist keine Tour zugewiesen.', + style: TextStyle(fontSize: 16), + textAlign: TextAlign.center, + ), + ), + ), + ); + } if (state is! TourLoaded) { return const Scaffold( body: Center(child: CircularProgressIndicator()), ); } - final available = state.tour.deliveries - .where((d) => d.carId == null) + final details = state.details; + final available = details.deliveries + .where((d) => d.assignedCarId == null) .toList(); - final assigned = state.tour.deliveries - .where((d) => d.carId != null) + final assigned = details.deliveries + .where((d) => d.assignedCarId != null) .toList(); - // Falls eine selektierte Lieferung in der Zwischenzeit zugeordnet - // wurde (z. B. durch einen parallelen Vorgang), Selektion bereinigen. _selectedIds.removeWhere( (id) => !available.any((d) => d.id == id), ); @@ -442,22 +416,20 @@ class _DeliverySelectionPageState extends State { children: [ PhaseStepper( currentPhase: DeliveryPhase.auswaehlen, - carId: _carIdString, + carId: widget.selectedCarId, ), Material( color: Theme.of(context).primaryColor, child: TabBar( - labelColor: - Theme.of(context).colorScheme.onPrimary, + labelColor: Theme.of(context).colorScheme.onPrimary, unselectedLabelColor: Theme.of(context) .colorScheme .onPrimary .withValues(alpha: 0.6), - indicatorColor: - Theme.of(context).colorScheme.onPrimary, + indicatorColor: Theme.of(context).colorScheme.onPrimary, tabs: [ - Tab(text: "Verfügbar (${available.length})"), - Tab(text: "Vergeben (${assigned.length})"), + Tab(text: 'Verfügbar (${available.length})'), + Tab(text: 'Vergeben (${assigned.length})'), ], ), ), @@ -466,8 +438,8 @@ class _DeliverySelectionPageState extends State { ), body: TabBarView( children: [ - _availableTab(available), - _assignedTab(assigned, state.tour), + _availableTab(available, details), + _assignedTab(assigned, details), ], ), bottomNavigationBar: _buildBottomBar(), diff --git a/lib/feature/delivery/overview/presentation/delivery_sort_page.dart b/lib/feature/delivery/overview/presentation/delivery_sort_page.dart index f540af3..8a5ac67 100644 --- a/lib/feature/delivery/overview/presentation/delivery_sort_page.dart +++ b/lib/feature/delivery/overview/presentation/delivery_sort_page.dart @@ -1,5 +1,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:hl_lieferservice/domain/entity/delivery.dart'; +import 'package:hl_lieferservice/domain/entity/tour_details.dart'; +import 'package:hl_lieferservice/feature/cars/bloc/cars_bloc.dart'; +import 'package:hl_lieferservice/feature/cars/bloc/cars_state.dart'; import 'package:hl_lieferservice/feature/delivery/bloc/phase_bloc.dart'; import 'package:hl_lieferservice/feature/delivery/bloc/phase_event.dart'; import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.dart'; @@ -12,9 +16,15 @@ import 'package:hl_lieferservice/widget/phase_stepper/phase_stepper.dart'; /// Page für die zweite Phase des Lieferprozesses (Sortieren). Der Fahrer /// legt per Drag&Drop die Reihenfolge fest, ändert lokal so oft er möchte -/// und bestätigt am Ende mit einem expliziten Klick. Erst dann wird die +/// und bestätigt am Ende mit einem expliziten Klick. Dann wird die /// Reihenfolge ans Backend übertragen und in Phase [DeliveryPhase.beladen] /// gewechselt. +/// +/// Hinweis zum Backend-Endpoint: +/// `PUT /tours/{id}/delivery-order` erwartet die **vollständige** Reihenfolge +/// aller Lieferungen der Tour. Bei Mehr-Auto-Teams sortiert der Fahrer +/// trotzdem nur „seine" Lieferungen — die Reihenfolge der fremden Lieferungen +/// wird unverändert anhand der aktuellen `sortOrder` mitgeschickt. class DeliverySortPage extends StatefulWidget { const DeliverySortPage({ super.key, @@ -23,9 +33,6 @@ class DeliverySortPage extends StatefulWidget { }); final String selectedCarId; - - /// Optionaler Hook, damit die übergeordnete Routing-Stelle nach Erfolg - /// auf die nächste Phase wechseln kann (rerender der Beladungs-Page). final VoidCallback? onPhaseAdvanced; @override @@ -33,82 +40,88 @@ class DeliverySortPage extends StatefulWidget { } class _DeliverySortPageState extends State { - late final SortableDeliveryListController _listController; + final SortableDeliveryListController _listController = + SortableDeliveryListController(); /// Verhindert mehrfache SnackBars für denselben Fehler-State. String? _lastShownErrorSignature; - /// Letzter Tour-"Fingerabdruck" (Anzahl + erste/letzte ID), zu dem wir den - /// Sortier-Bucket des aktuellen Autos bereits konsistent gemacht haben. - /// Verhindert unnötige Event-Stürme, wenn der TourBloc häufig rebuildet. - String? _lastEnsuredTourSignature; + /// True, sobald `_confirm` gefeuert wurde — wir nutzen das, um den + /// Übergang persisting → fertig zuverlässig zu erkennen. + bool _isAwaitingConfirm = false; - /// Trackt, ob im letzten Listener-Tick `isPersistingSorting` true war — - /// damit wir den Übergang persisting → fertig zuverlässig erkennen, - /// auch wenn der Listener für Bucket-Maintenance bereits zwischendurch - /// gefeuert hat. - bool _wasPersisting = false; - - @override - void initState() { - super.initState(); - _listController = SortableDeliveryListController(); - // Falls Tour bereits geladen ist: Sortier-Bucket sofort konsistent - // machen. Sonst übernimmt das der BlocConsumer-Listener beim ersten - // TourLoaded. - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; - _ensureBucketIfNeeded(context.read().state); - }); + bool _multiCarTeam(BuildContext context) { + final carsState = context.read().state; + return carsState is CarsLoaded && carsState.cars.length >= 2; } - String _tourSignature(TourLoaded state) { - final ids = state.tour.deliveries.map((d) => d.id).toList(); - if (ids.isEmpty) return "0"; - return "${ids.length}|${ids.first}|${ids.last}"; + List _ownDeliveries(TourDetails details) { + if (_multiCarTeam(context)) { + return details.deliveriesSorted + .where((d) => d.assignedCarId == widget.selectedCarId) + .toList(); + } + return details.deliveriesSorted; } - void _ensureBucketIfNeeded(TourState state) { - if (state is! TourLoaded) return; - final signature = _tourSignature(state); - if (signature == _lastEnsuredTourSignature) return; - _lastEnsuredTourSignature = signature; - context.read().add( - EnsureSortingForCarEvent(carId: widget.selectedCarId.toString()), - ); + /// Baut die vollständige Tour-Reihenfolge: die fremden Lieferungen bleiben + /// in ihrer aktuellen `sortOrder`, die eigenen werden in der vom Fahrer + /// gewählten Reihenfolge eingefügt — an den Positionen, an denen sie + /// vorher waren. Beispiel: hat der Fahrer „eigene" Plätze 1, 3, 5 + /// belegt und sortiert lokal um, dann landen seine neuen Reihen-Ids + /// in derselben Reihenfolge auf den Positionen 1, 3, 5; fremde Items + /// behalten ihre Plätze 2, 4. + List _buildFullTourOrder( + TourDetails details, + List ownOrderedIds, + ) { + final sorted = details.deliveriesSorted; + if (!_multiCarTeam(context)) return ownOrderedIds; + + final ownSlotsByPosition = {}; + final foreignByPosition = {}; + for (var i = 0; i < sorted.length; i++) { + final d = sorted[i]; + if (d.assignedCarId == widget.selectedCarId) { + ownSlotsByPosition[i] = d.id; + } else { + foreignByPosition[i] = d.id; + } + } + final ownPositions = ownSlotsByPosition.keys.toList()..sort(); + + final result = List.filled(sorted.length, null); + for (final entry in foreignByPosition.entries) { + result[entry.key] = entry.value; + } + for (var i = 0; i < ownPositions.length && i < ownOrderedIds.length; i++) { + result[ownPositions[i]] = ownOrderedIds[i]; + } + // Schließe verbleibende Lücken auf — sollte normalerweise leer sein. + return result.whereType().toList(); } - List _orderedIdsFor(TourLoaded state) { - return state.sortingInformation[widget.selectedCarId.toString()] ?? - const []; - } - - /// Phase im zentralen [PhaseBloc] auf "beladen" setzen. Der Bloc kümmert - /// sich um Persistenz via [PhaseService]. Der optionale UI-Callback wird - /// (aus Rückwärtskompatibilität) zusätzlich gefeuert. void _advanceToLoading() { context.read().add( PhaseSet( - carId: widget.selectedCarId.toString(), + carId: widget.selectedCarId, phase: DeliveryPhase.beladen, ), ); widget.onPhaseAdvanced?.call(); } - void _skipEmptyToLoading() => _advanceToLoading(); - - void _confirm() { - // _wasPersisting hier setzen — der erste Listener-Tick mit - // isPersistingSorting=true wird von listenWhen herausgefiltert - // (kein "phaseEnded"-Übergang), daher müssen wir das Flag vorher - // selbst hochziehen. Sonst erkennt der Listener beim zweiten Tick - // (isPersistingSorting=false) den Übergang nicht und der - // Phasen-Wechsel auf "beladen" bleibt aus. - _wasPersisting = true; + void _confirm(TourDetails details) { + final ownOrder = _listController.readCurrentOrder(); + final fullOrder = _buildFullTourOrder(details, ownOrder); + if (fullOrder.isEmpty) { + _advanceToLoading(); + return; + } + _isAwaitingConfirm = true; context.read().add( - ConfirmSortingEvent(carId: widget.selectedCarId.toString()), - ); + ReorderDeliveries(orderedDeliveryIds: fullOrder), + ); } Widget _hintCard({required IconData icon, required String text, Color? color}) { @@ -133,14 +146,14 @@ class _DeliverySortPageState extends State { const Icon(Icons.inbox_outlined, size: 64, color: Colors.grey), const SizedBox(height: 12), Text( - "Keine Lieferungen heute", + 'Keine Lieferungen heute', style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(height: 6), const Padding( padding: EdgeInsets.symmetric(horizontal: 32), child: Text( - "Für das ausgewählte Fahrzeug sind heute keine Lieferungen geplant.", + 'Für das ausgewählte Fahrzeug sind heute keine Lieferungen geplant.', textAlign: TextAlign.center, ), ), @@ -148,33 +161,29 @@ class _DeliverySortPageState extends State { ); } - Widget _singleDeliveryHint(String singleId, TourLoaded state) { - final delivery = state.tour.deliveries.firstWhere( - (d) => d.id == singleId, - orElse: () => state.tour.deliveries.first, - ); + Widget _singleDeliveryHint(Delivery single, TourDetails details) { + final customer = details.customerOf(single); return Column( children: [ _hintCard( icon: Icons.info_outline, - text: - "Nur eine Lieferung — die Reihenfolge ist trivial. " - "Tippen Sie auf \"Weiter zur Beladung\", um fortzufahren.", + text: 'Nur eine Lieferung — die Reihenfolge ist trivial. ' + 'Tippen Sie auf "Weiter zur Beladung", um fortzufahren.', ), const Divider(), ListTile( leading: CircleAvatar( backgroundColor: Theme.of(context).primaryColor, child: Text( - "1", + '1', style: TextStyle( color: Theme.of(context).colorScheme.onSecondary, ), ), ), - title: Text(delivery.customer.name), + title: Text(customer?.name ?? '⟨Unbekannter Kunde⟩'), subtitle: Text( - delivery.customer.address.toString(), + single.deliveryAddressSnapshot.oneLine, style: const TextStyle(fontSize: 11), ), ), @@ -186,55 +195,48 @@ class _DeliverySortPageState extends State { Widget build(BuildContext context) { return BlocConsumer( listenWhen: (prev, curr) { - // Bucket-Maintenance: erstes TourLoaded oder Tour-Inhalt geändert. - final bucketTrigger = (prev is! TourLoaded && curr is TourLoaded) || - (prev is TourLoaded && - curr is TourLoaded && - prev.tour.deliveries.length != curr.tour.deliveries.length); - if (bucketTrigger) return true; - if (prev is! TourLoaded || curr is! TourLoaded) return false; final phaseEnded = - prev.isPersistingSorting && !curr.isPersistingSorting; - final newError = - curr.sortingPersistError != null && - curr.sortingPersistError != prev.sortingPersistError; + prev.isPersistingReorder && !curr.isPersistingReorder; + final newError = curr.reorderError != null && + curr.reorderError != prev.reorderError; return phaseEnded || newError; }, listener: (context, state) { if (state is! TourLoaded) return; - // 1) Bucket-Konsistenz nachziehen, falls die Tour gerade erst geladen - // wurde oder sich verändert hat. - _ensureBucketIfNeeded(state); - - // 2) Fehler-SnackBar für Confirm-Fehler. - final err = state.sortingPersistError; + final err = state.reorderError; if (err != null && err != _lastShownErrorSignature) { _lastShownErrorSignature = err; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(err)), ); - _wasPersisting = state.isPersistingSorting; + _isAwaitingConfirm = false; return; } - // 3) Echter Übergang persisting → fertig + kein Fehler → erfolgreich - // bestätigt → nächste Phase. Übergang über das Feld _wasPersisting - // erkannt, da der Listener auch bei Bucket-Triggern feuert. - if (_wasPersisting && !state.isPersistingSorting && err == null) { + if (_isAwaitingConfirm && + !state.isPersistingReorder && + err == null) { + _isAwaitingConfirm = false; _advanceToLoading(); } - _wasPersisting = state.isPersistingSorting; }, builder: (context, state) { + if (state is TourEmpty) { + return Scaffold( + appBar: AppBar(title: const Text('Sortieren')), + body: _emptyState(), + ); + } if (state is! TourLoaded) { return const Scaffold( body: Center(child: CircularProgressIndicator()), ); } - final orderedIds = _orderedIdsFor(state); + final details = state.details; + final ownDeliveries = _ownDeliveries(details); return Scaffold( drawer: const HomeAppDrawer(), @@ -242,40 +244,41 @@ class _DeliverySortPageState extends State { preferredSize: const Size.fromHeight(140), child: PhaseStepper( currentPhase: DeliveryPhase.sortieren, - carId: widget.selectedCarId.toString(), + carId: widget.selectedCarId, ), ), - body: SafeArea( - child: _buildBody(state, orderedIds), - ), - bottomNavigationBar: _buildBottomBar(state, orderedIds), + body: SafeArea(child: _buildBody(state, details, ownDeliveries)), + bottomNavigationBar: + _buildBottomBar(state, details, ownDeliveries), ); }, ); } - Widget _buildBody(TourLoaded state, List orderedIds) { - if (orderedIds.isEmpty) { + Widget _buildBody( + TourLoaded state, + TourDetails details, + List ownDeliveries, + ) { + if (ownDeliveries.isEmpty) { return _emptyState(); } - - if (orderedIds.length == 1) { - return _singleDeliveryHint(orderedIds.first, state); + if (ownDeliveries.length == 1) { + return _singleDeliveryHint(ownDeliveries.first, details); } - return Column( children: [ _hintCard( icon: Icons.info_outline, - text: - "Ziehen Sie die einzelnen Lieferungen mit dem Finger in die " - "gewünschte Position. Die Reihenfolge wird erst beim " - "Bestätigen ans System übertragen.", + text: 'Ziehen Sie die einzelnen Lieferungen mit dem Finger in die ' + 'gewünschte Position. Die Reihenfolge wird erst beim ' + 'Bestätigen ans System übertragen.', ), const Divider(height: 1), Expanded( child: SortableDeliveryList( - selectedCarId: widget.selectedCarId, + details: details, + deliveries: ownDeliveries, controller: _listController, ), ), @@ -283,35 +286,38 @@ class _DeliverySortPageState extends State { ); } - Widget _buildBottomBar(TourLoaded state, List orderedIds) { - final isLoading = state.isPersistingSorting; + Widget _buildBottomBar( + TourLoaded state, + TourDetails details, + List ownDeliveries, + ) { + final isLoading = state.isPersistingReorder; final theme = Theme.of(context); - // Spezialfälle: 0 / 1 Lieferungen → vereinfachte BottomBar - if (orderedIds.isEmpty) { + if (ownDeliveries.isEmpty) { return SafeArea( child: Padding( padding: const EdgeInsets.all(12), child: SizedBox( width: double.infinity, child: FilledButton.icon( - onPressed: isLoading ? null : _skipEmptyToLoading, + onPressed: isLoading ? null : _advanceToLoading, icon: const Icon(Icons.arrow_forward), - label: const Text("Weiter zur Beladung"), + label: const Text('Weiter zur Beladung'), ), ), ), ); } - if (orderedIds.length == 1) { + if (ownDeliveries.length == 1) { return SafeArea( child: Padding( padding: const EdgeInsets.all(12), child: SizedBox( width: double.infinity, child: FilledButton.icon( - onPressed: isLoading ? null : _confirm, + onPressed: isLoading ? null : () => _confirm(details), icon: isLoading ? const SizedBox( width: 16, @@ -322,7 +328,7 @@ class _DeliverySortPageState extends State { ), ) : const Icon(Icons.arrow_forward), - label: const Text("Weiter zur Beladung"), + label: const Text('Weiter zur Beladung'), ), ), ), @@ -336,18 +342,16 @@ class _DeliverySortPageState extends State { children: [ Expanded( child: OutlinedButton.icon( - onPressed: isLoading - ? null - : () => _listController.resetToDefault(), + onPressed: isLoading ? null : _listController.resetToDefault, icon: const Icon(Icons.restart_alt), - label: const Text("Zurücksetzen"), + label: const Text('Zurücksetzen'), ), ), const SizedBox(width: 12), Expanded( flex: 2, child: FilledButton.icon( - onPressed: isLoading ? null : _confirm, + onPressed: isLoading ? null : () => _confirm(details), icon: isLoading ? SizedBox( width: 16, @@ -358,7 +362,7 @@ class _DeliverySortPageState extends State { ), ) : const Icon(Icons.check), - label: const Text("Reihenfolge bestätigen"), + label: const Text('Reihenfolge bestätigen'), ), ), ], diff --git a/lib/feature/delivery/overview/service/distance_service.dart b/lib/feature/delivery/overview/service/distance_service.dart deleted file mode 100644 index 44eac69..0000000 --- a/lib/feature/delivery/overview/service/distance_service.dart +++ /dev/null @@ -1,82 +0,0 @@ -import 'package:flutter/cupertino.dart'; -import 'package:geolocator/geolocator.dart'; -import 'package:http/http.dart' as http; -import 'dart:convert'; - -class DistanceService { - static const String GOOGLE_MAPS_API_KEY = 'DEIN_API_KEY_HIER'; - - static Future getCurrentLocation() async { - bool serviceEnabled = await Geolocator.isLocationServiceEnabled(); - if (!serviceEnabled) { - throw Exception('Location services sind deaktiviert'); - } - - LocationPermission permission = await Geolocator.checkPermission(); - if (permission == LocationPermission.denied) { - permission = await Geolocator.requestPermission(); - } - - return await Geolocator.getCurrentPosition(); - } - - // Adresse in Koordinaten umwandeln (Geocoding) - static Future> getCoordinates(String address) async { - String url = - 'https://maps.googleapis.com/maps/api/geocode/json' - '?address=${Uri.encodeComponent(address)}' - '&key=AIzaSyB5_1ftLnoswoy59FzNFkrQ7SSDma5eu5E'; - - final response = await http.get(Uri.parse(url)); - - if (response.statusCode == 200) { - var json = jsonDecode(response.body); - - if (json['results'].isNotEmpty) { - var location = json['results'][0]['geometry']['location']; - return { - 'lat': location['lat'], - 'lng': location['lng'], - }; - } - throw Exception('Adresse nicht gefunden'); - } - throw Exception('Geocoding Fehler: ${response.statusCode}'); - } - - // Distanz berechnen - static Future getDistanceByRoad(String address) async { - try { - Position currentPos = await getCurrentLocation(); - Map coords = await getCoordinates(address); - - String origin = "${currentPos.latitude},${currentPos.longitude}"; - String destination = "${coords['lat']},${coords['lng']}"; - - String url = - 'https://maps.googleapis.com/maps/api/distancematrix/json' - '?origins=$origin' - '&destinations=$destination' - '&key=AIzaSyB5_1ftLnoswoy59FzNFkrQ7SSDma5eu5E'; - - final response = await http.get(Uri.parse(url)); - - debugPrint(response.body); - - if (response.statusCode == 200) { - var json = jsonDecode(response.body); - - if (json['rows'][0]['elements'][0]['status'] == 'OK') { - int distanceMeters = json['rows'][0]['elements'][0]['distance']['value']; - return distanceMeters / 1000; // In km - } else { - throw Exception('Route nicht gefunden'); - } - } else { - throw Exception('API Fehler: ${response.statusCode}'); - } - } catch (e) { - throw Exception('Fehler: $e'); - } - } -} \ No newline at end of file diff --git a/lib/feature/delivery/overview/service/phase_service.dart b/lib/feature/delivery/overview/service/phase_service.dart index aea783f..9ca1f26 100644 --- a/lib/feature/delivery/overview/service/phase_service.dart +++ b/lib/feature/delivery/overview/service/phase_service.dart @@ -1,57 +1,52 @@ import 'package:hl_lieferservice/feature/delivery/model/delivery_phase.dart'; import 'package:shared_preferences/shared_preferences.dart'; -/// Persistiert die aktuelle Phase pro Fahrzeug. Der Key ist datumsspezifisch, -/// damit ein App-Neustart am nächsten Tag automatisch wieder mit Phase 1 -/// (Sortieren) startet — die Phase eines Vortags hat keine Bedeutung mehr. +/// Persistiert die aktuelle Phase pro Fahrzeug. /// -/// Zusätzlich wird die **höchste am Tag erreichte Phase** pro Fahrzeug -/// persistiert (eigener Key-Suffix `_max`). Der Stepper nutzt diesen Wert, -/// um Vorwärts-Sprünge auf bereits besuchte Phasen zu erlauben — auch wenn -/// der Fahrer zwischenzeitlich zurückgesprungen ist. +/// Der Key ist an einen **Tour-Token** gebunden (abgeleitet aus +/// `Tour.syncedAt`) statt nur an das Datum. Vorteile: +/// +/// * Ein erneuter ERP-Sync / Demo-Seed schreibt eine neue `syncedAt` → neuer +/// Token → die Phasen (inkl. der „erledigt"-Häkchen im Stepper) starten +/// frisch. So bleibt ein „Daten-Reset" im Backend nicht an alten lokalen +/// Häkchen hängen. +/// * Eine Tour von heute hat heutiges `syncedAt` — die Tagesbindung ist +/// damit implizit (am nächsten Tag gibt es ohnehin eine neue Tour). +/// * Bloßes Weiterscannen (Item-Status) ändert `syncedAt` nicht → der +/// Fahrer-Fortschritt bleibt über App-Neustarts derselben Tour erhalten. +/// +/// Zusätzlich wird die **höchste erreichte Phase** pro Fahrzeug persistiert +/// (Key-Suffix `_max`). Der Stepper nutzt das, um Vorwärts-Sprünge auf +/// bereits besuchte Phasen zu erlauben — auch nach einem Rücksprung. class PhaseService { static const _prefix = "delivery_phase"; - String _key(String carId) { - final now = DateTime.now(); - final date = "${now.year}_${now.month}_${now.day}"; - return "${_prefix}_${date}_$carId"; - } + String _key(String carId, String token) => "${_prefix}_${token}_$carId"; - String _maxKey(String carId) { - final now = DateTime.now(); - final date = "${now.year}_${now.month}_${now.day}"; - return "${_prefix}_max_${date}_$carId"; - } + String _maxKey(String carId, String token) => + "${_prefix}_max_${token}_$carId"; - Future save(String carId, DeliveryPhase phase) async { + Future save(String carId, String token, DeliveryPhase phase) async { final prefs = await SharedPreferences.getInstance(); - await prefs.setString(_key(carId), phase.persistenceKey); + await prefs.setString(_key(carId, token), phase.persistenceKey); } - Future load(String carId) async { - final prefs = await SharedPreferences.getInstance(); - return DeliveryPhaseExtension.fromPersistenceKey(prefs.getString(_key(carId))); - } - - Future saveMax(String carId, DeliveryPhase phase) async { - final prefs = await SharedPreferences.getInstance(); - await prefs.setString(_maxKey(carId), phase.persistenceKey); - } - - Future loadMax(String carId) async { + Future load(String carId, String token) async { final prefs = await SharedPreferences.getInstance(); return DeliveryPhaseExtension.fromPersistenceKey( - prefs.getString(_maxKey(carId)), + prefs.getString(_key(carId, token)), ); } - Future> loadAll(Iterable carIds) async { - final result = {}; - for (final carId in carIds) { - final phase = await load(carId); - if (phase != null) result[carId] = phase; - } - return result; + Future saveMax(String carId, String token, DeliveryPhase phase) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_maxKey(carId, token), phase.persistenceKey); + } + + Future loadMax(String carId, String token) async { + final prefs = await SharedPreferences.getInstance(); + return DeliveryPhaseExtension.fromPersistenceKey( + prefs.getString(_maxKey(carId, token)), + ); } } diff --git a/lib/feature/delivery/overview/service/reorder_service.dart b/lib/feature/delivery/overview/service/reorder_service.dart deleted file mode 100644 index 2da20c2..0000000 --- a/lib/feature/delivery/overview/service/reorder_service.dart +++ /dev/null @@ -1,74 +0,0 @@ -import 'dart:convert'; -import 'dart:io'; - -import 'package:flutter/cupertino.dart'; -import 'package:hl_lieferservice/model/tour.dart'; -import 'package:path_provider/path_provider.dart'; - -class ReorderService { - get _path async { - final dir = await getApplicationDocumentsDirectory(); - final date = DateTime.now(); - final filename = "custom_sort_${date.year}_${date.month}_${date.day}.json"; - final path = "${dir.path}/$filename"; - - return path; - } - - Future get _file async { - final path = await _path; - final file = File(path); - - return file; - } - - Future saveSortingInformation( - Map> container, - ) async { - debugPrint("CONTAINER: ${jsonEncode(container)}"); - - (await _file).writeAsString(jsonEncode(container)); - } - - Future initializeTour(Tour tour) async { - (await _file).create(); - Map> sorting = {}; - - for (final delivery in tour.deliveries) { - if (!sorting.containsKey(delivery.carId.toString())) { - sorting[delivery.carId.toString()] = [delivery.id]; - } else { - sorting[delivery.carId.toString()]!.add(delivery.id); - } - } - - (await _file).writeAsString(jsonEncode({"cars": sorting})); - } - - bool orderInformationExist() { - return false; - } - - Future>> loadSortingInformation() async { - debugPrint("FILE: ${await (await _file).readAsString()}"); - Map> container = {}; - Map json = jsonDecode(await (await _file).readAsString()); - - if (!json.containsKey("cars")) { - throw Exception("No cars found in file"); - } - - for (final car in json["cars"].entries) { - List values = []; - - for (String value in car.value) { - values.add(value); - } - - container[car.key] = values; - } - - - return container; - } -} diff --git a/lib/feature/delivery/overview/widget/sortable_delivery_list.dart b/lib/feature/delivery/overview/widget/sortable_delivery_list.dart index 6222f2e..824984a 100644 --- a/lib/feature/delivery/overview/widget/sortable_delivery_list.dart +++ b/lib/feature/delivery/overview/widget/sortable_delivery_list.dart @@ -1,30 +1,30 @@ import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.dart'; -import 'package:hl_lieferservice/feature/delivery/bloc/tour_event.dart'; -import 'package:hl_lieferservice/feature/delivery/bloc/tour_state.dart'; -import 'package:hl_lieferservice/feature/delivery/util.dart'; -import 'package:hl_lieferservice/model/delivery.dart'; +import 'package:hl_lieferservice/domain/entity/delivery.dart'; +import 'package:hl_lieferservice/domain/entity/tour_details.dart'; /// Drag&Drop-Liste der heutigen Lieferungen eines Fahrzeugs. /// -/// Hält die aktuell sichtbare Reihenfolge in lokalem State, damit das -/// Verschieben spürbar unmittelbar wirkt — und gibt jeden Drop zusätzlich -/// als [ReorderDeliveryEvent] an den [TourBloc] weiter. Dort übernimmt -/// der [ReorderService] die lokale Persistenz. Es findet hier bewusst -/// **kein** API-Call statt; der Backend-Sync läuft erst, wenn der Fahrer -/// die Reihenfolge in der übergeordneten Page bestätigt. +/// Hält die aktuell sichtbare Reihenfolge in lokalem State; ein Tour-Reload +/// von außen würde sie überschreiben — das ist Absicht, damit der Backend- +/// Stand bei Pull-to-refresh durchschlägt. Der eigentliche Backend-Sync +/// erfolgt erst, wenn der Fahrer in der übergeordneten Page bestätigt. class SortableDeliveryList extends StatefulWidget { const SortableDeliveryList({ super.key, - required this.selectedCarId, + required this.details, + required this.deliveries, this.controller, }); - final String? selectedCarId; + /// Aggregat-Snapshot — wird für Kunden-Lookup gebraucht. + final TourDetails details; - /// Optionaler Controller zum Zurücksetzen der Liste durch Eltern-Widgets - /// (z. B. Button "Zurücksetzen" in der Page). + /// Die in der Liste anzuzeigenden Lieferungen, vorgefiltert vom Aufrufer + /// (z. B. nur die dem ausgewählten Fahrzeug zugewiesenen). + final List deliveries; + + /// Optionaler Controller zum Auslesen der aktuellen Reihenfolge und zum + /// Zurücksetzen durch Eltern-Widgets. final SortableDeliveryListController? controller; @override @@ -32,12 +32,12 @@ class SortableDeliveryList extends StatefulWidget { } class _SortableDeliveryListState extends State { - late List _localSortedList; + late List _orderedIds; @override void initState() { super.initState(); - _localSortedList = _readSortedListFromBloc(); + _orderedIds = widget.deliveries.map((d) => d.id).toList(growable: true); widget.controller?._attach(this); } @@ -48,6 +48,17 @@ class _SortableDeliveryListState extends State { oldWidget.controller?._detach(this); widget.controller?._attach(this); } + // Wenn sich die Eingangsliste fundamental ändert (Tour-Reload, neue + // Lieferung hinzu/weg), local-state neu synchronisieren. + final incomingIds = widget.deliveries.map((d) => d.id).toSet(); + final localIds = _orderedIds.toSet(); + if (incomingIds.length != localIds.length || + !incomingIds.containsAll(localIds)) { + setState(() { + _orderedIds = + widget.deliveries.map((d) => d.id).toList(growable: true); + }); + } } @override @@ -56,115 +67,64 @@ class _SortableDeliveryListState extends State { super.dispose(); } - List _readSortedListFromBloc() { - final state = context.read().state; - if (state is TourLoaded) { - return [ - ...state.sortingInformation[widget.selectedCarId.toString()] ?? [], - ]; - } - return []; - } - - /// Setzt die Liste auf die natürliche Reihenfolge zurück, in der die - /// Lieferungen in der Tour stehen. Wird vom Controller (Button - /// "Zurücksetzen") aufgerufen und meldet jeden notwendigen Swap als - /// Reorder-Event, damit der lokale Persistenz-State synchron bleibt. - /// - /// Filterlogik (muss konsistent zu `_ensureSortingForCar` im TourBloc - /// sein): - /// * Ein-Auto-Teams: alle Tour-Lieferungen. - /// * Mehr-Auto-Teams: nur Lieferungen, die dem ausgewählten Fahrzeug - /// nach der Auswahl bereits zugeordnet sind. void _resetToDefault() { - final state = context.read().state; - if (state is! TourLoaded) return; - - final cars = state.tour.driver.cars; - final carIdStr = widget.selectedCarId.toString(); - final List defaultOrder = cars.length >= 2 - ? state.tour.deliveries - .where((d) => d.carId?.toString() == carIdStr) - .map((d) => d.id) - .toList() - : state.tour.deliveries.map((d) => d.id).toList(); - setState(() { - _localSortedList = [...defaultOrder]; + _orderedIds = + widget.deliveries.map((d) => d.id).toList(growable: true); }); - - final container = { - ...state.sortingInformation, - carIdStr: [...defaultOrder], - }; - context.read().add( - ReplaceSortingEvent( - carId: carIdStr, - newSortingInformation: container, - ), - ); } + List _readCurrentOrder() => List.of(_orderedIds); + @override Widget build(BuildContext context) { - return BlocBuilder( - builder: (context, state) { - if (state is! TourLoaded) { - return const Center(child: CircularProgressIndicator()); - } + final byId = {for (final d in widget.deliveries) d.id: d}; - return ReorderableListView( - buildDefaultDragHandles: true, - onReorder: (oldIndex, newIndex) { - setState(() { - _localSortedList = reorderList( - _localSortedList, - oldIndex, - newIndex, - ); - }); - - context.read().add( - ReorderDeliveryEvent( - newPosition: newIndex, - oldPosition: oldIndex, - carId: widget.selectedCarId.toString(), - ), - ); - }, - children: _localSortedList.map((id) { - final Delivery delivery = state.tour.deliveries.firstWhere( - (delivery) => delivery.id == id, - ); - final int pos = _localSortedList.indexOf(id) + 1; - - return ListTile( - key: Key("reorder-item-${delivery.id}"), - leading: CircleAvatar( - backgroundColor: Theme.of(context).primaryColor, - child: Text( - "$pos", - style: TextStyle( - color: Theme.of(context).colorScheme.onSecondary, - ), - ), - ), - title: Text(delivery.customer.name), - subtitle: Text( - delivery.customer.address.toString(), - style: const TextStyle(fontSize: 11), - ), - trailing: const Icon(Icons.drag_handle), - ); - }).toList(), - ); + return ReorderableListView( + buildDefaultDragHandles: true, + onReorder: (oldIndex, newIndex) { + setState(() { + if (newIndex > oldIndex) newIndex -= 1; + final id = _orderedIds.removeAt(oldIndex); + _orderedIds.insert(newIndex, id); + }); }, + children: _orderedIds.asMap().entries.map((entry) { + final id = entry.value; + final pos = entry.key + 1; + final delivery = byId[id]; + if (delivery == null) { + return ListTile( + key: Key('reorder-item-orphan-$id'), + title: Text('Lieferung $id nicht mehr in der Tour'), + ); + } + final customer = widget.details.customerOf(delivery); + return ListTile( + key: Key('reorder-item-${delivery.id}'), + leading: CircleAvatar( + backgroundColor: Theme.of(context).primaryColor, + child: Text( + '$pos', + style: TextStyle( + color: Theme.of(context).colorScheme.onSecondary, + ), + ), + ), + title: Text(customer?.name ?? '⟨Unbekannter Kunde⟩'), + subtitle: Text( + delivery.deliveryAddressSnapshot.oneLine, + style: const TextStyle(fontSize: 11), + ), + trailing: const Icon(Icons.drag_handle), + ); + }).toList(), ); } } /// Schmaler Controller, mit dem Eltern-Widgets die Liste zurücksetzen -/// können, ohne den internen State direkt anzufassen. +/// und die aktuelle Reihenfolge auslesen können. class SortableDeliveryListController { _SortableDeliveryListState? _state; @@ -173,6 +133,11 @@ class SortableDeliveryListController { if (_state == state) _state = null; } - /// Setzt die Liste auf die Default-Reihenfolge (Tour-Reihenfolge) zurück. + /// Setzt die Liste auf die vom Aufrufer übergebene Default-Reihenfolge + /// zurück (= aktueller `widget.deliveries`-Stand). void resetToDefault() => _state?._resetToDefault(); + + /// Aktuelle Reihenfolge der Delivery-IDs, wie sie der Fahrer sieht. + List readCurrentOrder() => + _state?._readCurrentOrder() ?? const []; } diff --git a/lib/feature/delivery/pickup/presentation/filiale_pickup_scan_page.dart b/lib/feature/delivery/pickup/presentation/filiale_pickup_scan_page.dart new file mode 100644 index 0000000..df2de07 --- /dev/null +++ b/lib/feature/delivery/pickup/presentation/filiale_pickup_scan_page.dart @@ -0,0 +1,457 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'package:hl_lieferservice/domain/entity/delivery.dart'; +import 'package:hl_lieferservice/domain/entity/delivery_item.dart'; +import 'package:hl_lieferservice/domain/entity/tour_details.dart'; +import 'package:hl_lieferservice/feature/car_selection/bloc/bloc.dart'; +import 'package:hl_lieferservice/feature/car_selection/bloc/state.dart'; +import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.dart'; +import 'package:hl_lieferservice/feature/delivery/bloc/tour_event.dart'; +import 'package:hl_lieferservice/feature/delivery/bloc/tour_state.dart'; +import 'package:hl_lieferservice/widget/scanner/article_scanner_stripe.dart'; +import 'package:hl_lieferservice/widget/scanner/item_matcher.dart'; +import 'package:hl_lieferservice/widget/scanner/manual_entry_dialog.dart'; +import 'package:hl_lieferservice/widget/scanner/scan_code_parser.dart'; + +/// Scan-Screen für die Filial-Abholung in der Auslieferungs-Phase. +/// +/// Der Fahrer hat in der Beladen-Phase das Standardlager beladen. Artikel +/// aus einer Filiale waren noch offen — bevor er ausliefert, muss er zur +/// Filiale, die Ware holen und hier abscannen. Diese Page wird beim Tap auf +/// eine Lieferung mit offenen Filial-Artikeln geöffnet (siehe +/// `DeliveryOverview`). +/// +/// Fokus auf **eine** Lieferung + **nur** deren Filial-Items. Gleiche +/// QR-Validierung (`Artikelnr;Kundennr;Belegnr`) und derselbe `ScanItem`- +/// Pfad wie die Beladen-Phase — über die geteilten Scanner-Module. +/// +/// Nach vollständigem Scan: Erfolgs-Zustand + Button „zurück zur Übersicht". +/// Der Fahrer fährt dann zum Kunden; dort öffnet ein erneuter Tap die +/// eigentliche Auslieferung (`DeliveryDetail`). +class FilialePickupScanPage extends StatefulWidget { + const FilialePickupScanPage({super.key, required this.deliveryId}); + + final String deliveryId; + + @override + State createState() => _FilialePickupScanPageState(); +} + +class _FilialePickupScanPageState extends State { + static const String _notIntendedMessage = + 'Dieser Artikel gehört nicht zu dieser Filial-Abholung'; + + String _carId(BuildContext context) { + final state = context.read().state; + if (state is CarSelectComplete) return state.selectedCar.id; + return '00000000-0000-0000-0000-000000000000'; + } + + void _showSnackbar(String message) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message), duration: const Duration(seconds: 2)), + ); + } + + /// Nur scanbare, nicht-entfernte Filial-Items dieser Lieferung, + /// aufsteigend nach Belegzeile. + List _externalItems(Delivery delivery, TourDetails details) { + final items = delivery.items.where((it) { + if (it.isRemoved) return false; + if (!details.isArticleScannable(it.articleId)) return false; + final w = details.warehouseOf(it.warehouseId); + return w != null && !w.isStandard; + }).toList() + // Oberartikel vor seinen Komponenten (für eingerückte Darstellung). + ..sort((a, b) { + final byLine = a.belegzeilenNr.compareTo(b.belegzeilenNr); + if (byLine != 0) return byLine; + final byParent = + (a.isComponent ? 1 : 0).compareTo(b.isComponent ? 1 : 0); + if (byParent != 0) return byParent; + return (a.komponentenArtikelNr ?? '') + .compareTo(b.komponentenArtikelNr ?? ''); + }); + return items; + } + + void _onBarcode({ + required String code, + required Delivery delivery, + required TourDetails details, + }) { + final customer = details.customerOf(delivery); + final parsed = parseScanCode(code); + if (parsed == null || + customer?.erpCustomerId != parsed.customerErpId || + delivery.erpBelegnummer != parsed.beleg) { + _showSnackbar(_notIntendedMessage); + return; + } + + final match = matchItem( + delivery: delivery, + details: details, + articleNumber: parsed.articleNumber, + // Nur Filial-Items zählen — ein Standardlager-Artikel hat hier nichts + // zu suchen (der ist längst auf dem LKW). + itemFilter: (it) { + final w = details.warehouseOf(it.warehouseId); + return w != null && !w.isStandard; + }, + ); + switch (match) { + case ItemMatchOk(:final item): + context + .read() + .add(ScanItem(deliveryItemId: item.id, actorCarId: _carId(context))); + case ItemMatchNotInDelivery(): + _showSnackbar(_notIntendedMessage); + case ItemMatchNotScannable(): + _showSnackbar('Diese Position ist nicht zum Scannen vorgesehen.'); + case ItemMatchAllDone(): + _showSnackbar('Dieser Artikel ist bereits geladen.'); + case ItemMatchAllRemoved(): + _showSnackbar('Diese Position wurde aus der Lieferung entfernt.'); + case ItemMatchNotOpen(): + _showSnackbar('Diese Position ist nicht (mehr) offen.'); + } + } + + Future _onManualEntry({ + required Delivery delivery, + required TourDetails details, + }) async { + final code = await showManualEntryDialog(context); + if (code == null || code.isEmpty || !mounted) return; + _onBarcode(code: code, delivery: delivery, details: details); + } + + /// Fallback ohne Barcode: die ganze Restmenge der Filial-Position manuell + /// als geholt bestätigen. Bewusste Aussage → Bestätigungs-Dialog; das + /// Backend protokolliert den Scan als `manual`. + Future _onManualConfirm(DeliveryItem item) async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Manuell bestätigen'), + content: const Text( + 'Diese Position ohne Scan als aus der Filiale geholt markieren? ' + 'Das wird als manuelle Bestätigung protokolliert.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('Abbrechen'), + ), + FilledButton( + onPressed: () => Navigator.of(ctx).pop(true), + child: const Text('Als geholt bestätigen'), + ), + ], + ), + ); + if (confirmed != true || !mounted) return; + context.read().add(ScanItem( + deliveryItemId: item.id, + actorCarId: _carId(context), + manual: true, + )); + } + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + if (state is! TourLoaded) { + return const Scaffold( + body: Center(child: CircularProgressIndicator()), + ); + } + final details = state.details; + final delivery = _findDelivery(details); + if (delivery == null) { + return Scaffold( + appBar: AppBar(title: const Text('Filial-Abholung')), + body: const Center( + child: Text('Lieferung nicht in der Tour gefunden.'), + ), + ); + } + + final customer = details.customerOf(delivery); + final externalItems = _externalItems(delivery, details); + final doneCount = externalItems.where((it) => it.isDone).length; + final allDone = externalItems.isNotEmpty && doneCount == externalItems.length; + final warehouseNames = details.externalWarehouseLabels(delivery); + + return Scaffold( + appBar: AppBar( + backgroundColor: Theme.of(context).colorScheme.primary, + foregroundColor: Theme.of(context).colorScheme.onPrimary, + title: Text( + warehouseNames.isEmpty + ? 'Filial-Abholung' + : 'Abholung: ${warehouseNames.join(", ")}', + ), + ), + body: Column( + children: [ + ArticleScannerStripe( + onBarcode: (code) => + _onBarcode(code: code, delivery: delivery, details: details), + onManualEntry: () => + _onManualEntry(delivery: delivery, details: details), + ), + Expanded( + child: ListView( + // Wenn alle Artikel gescannt sind, deckt die _DoneBar den + // Inset ab; vorher endet die Liste unten frei — daher hier + // die System-Navigationsleiste freihalten. + padding: EdgeInsets.fromLTRB( + 16, + 12, + 16, + 24 + (allDone ? 0 : MediaQuery.viewPaddingOf(context).bottom), + ), + children: [ + _Header( + customerName: customer?.name ?? '⟨Unbekannter Kunde⟩', + belegnummer: delivery.erpBelegnummer, + doneCount: doneCount, + total: externalItems.length, + ), + const SizedBox(height: 12), + for (final item in externalItems) + _ExternalItemRow( + item: item, + details: details, + onManualConfirm: () => _onManualConfirm(item), + ), + ], + ), + ), + if (allDone) _DoneBar(onConfirm: () => Navigator.of(context).pop()), + ], + ), + ); + }, + ); + } + + Delivery? _findDelivery(TourDetails details) { + for (final d in details.deliveries) { + if (d.id == widget.deliveryId) return d; + } + return null; + } +} + +class _Header extends StatelessWidget { + const _Header({ + required this.customerName, + required this.belegnummer, + required this.doneCount, + required this.total, + }); + + final String customerName; + final String belegnummer; + final int doneCount; + final int total; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Card( + margin: EdgeInsets.zero, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.warehouse_outlined, color: theme.colorScheme.primary), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Artikel aus der Filiale holen & scannen', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + Text( + '$doneCount / $total', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + color: doneCount == total + ? Colors.green.shade700 + : theme.colorScheme.primary, + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + '$customerName · Beleg-Nr. $belegnummer', + style: TextStyle( + fontSize: 13, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: total == 0 ? 0 : doneCount / total, + minHeight: 6, + backgroundColor: theme.colorScheme.surfaceContainerHighest, + valueColor: AlwaysStoppedAnimation( + doneCount == total + ? Colors.green.shade600 + : theme.colorScheme.primary, + ), + ), + ), + ], + ), + ), + ); + } +} + +class _ExternalItemRow extends StatelessWidget { + const _ExternalItemRow({ + required this.item, + required this.details, + required this.onManualConfirm, + }); + + final DeliveryItem item; + final TourDetails details; + final VoidCallback onManualConfirm; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final article = details.articleOf(item.articleId); + final warehouse = details.warehouseOf(item.warehouseId); + final done = item.isDone; + // Manueller Fallback nur für offene Positionen. Liste ist bereits + // scanbar + extern + nicht-entfernt gefiltert; hier nur done/held raus. + final canManualConfirm = !done && !item.isHeld; + + return Card( + // Komponenten eingerückt → gehören zum Oberartikel darüber. + margin: EdgeInsets.only(top: 8, left: item.isComponent ? 24 : 0), + elevation: 0, + color: done + ? Colors.green.withValues(alpha: 0.08) + : theme.colorScheme.surfaceContainerLow, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide( + color: done + ? Colors.green.withValues(alpha: 0.4) + : Colors.transparent, + ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: Icon( + done ? Icons.check_circle : Icons.radio_button_unchecked, + color: done + ? Colors.green.shade600 + : theme.colorScheme.onSurfaceVariant, + ), + title: Text( + '${item.isComponent ? '↳ ' : ''}${article?.name ?? '⟨Unbekannter Artikel⟩'}', + style: const TextStyle(fontWeight: FontWeight.w600), + ), + subtitle: Text( + [ + article?.articleNumber ?? item.articleId, + if (warehouse != null) warehouse.name, + ].join(' · '), + style: TextStyle( + fontSize: 12, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + trailing: Text( + '${item.scanProgress.scannedQuantity} / ${item.requiredQuantity}', + style: TextStyle( + fontWeight: FontWeight.bold, + color: done ? Colors.green.shade700 : theme.colorScheme.onSurface, + ), + ), + ), + if (canManualConfirm) + Padding( + padding: const EdgeInsets.fromLTRB(12, 0, 12, 12), + child: SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: onManualConfirm, + icon: const Icon(Icons.check_circle_outline, size: 18), + label: const Text('Manuell als geholt bestätigen'), + ), + ), + ), + ], + ), + ); + } +} + +class _DoneBar extends StatelessWidget { + const _DoneBar({required this.onConfirm}); + + final VoidCallback onConfirm; + + @override + Widget build(BuildContext context) { + return SafeArea( + top: false, + child: Container( + width: double.infinity, + padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), + color: Colors.green.withValues(alpha: 0.12), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Icon(Icons.check_circle, color: Colors.green.shade700), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Alle Filial-Artikel geladen', + style: TextStyle( + fontWeight: FontWeight.w700, + color: Colors.green.shade800, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: onConfirm, + icon: const Icon(Icons.arrow_back), + label: const Text('Fertig — zurück zur Übersicht'), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/feature/delivery/repository/process_repository.dart b/lib/feature/delivery/repository/process_repository.dart deleted file mode 100644 index 8ace83f..0000000 --- a/lib/feature/delivery/repository/process_repository.dart +++ /dev/null @@ -1,95 +0,0 @@ -import 'package:flutter/foundation.dart'; - -/// Repository für prozess-übergreifende Aktionen entlang des -/// Phasen-Workflows (Sortieren → Beladen → Ausliefern). -/// -/// Aktuell nur ein Stub: Sobald der entsprechende Backend-Endpunkt -/// von DOCUcontrol/VARIOcontrol bereitsteht, wird hier die echte -/// HTTP-Implementierung eingehängt — die Aufrufer (TourBloc) bleiben -/// dann unverändert. -class ProcessRepository { - ProcessRepository(); - - /// Persistiert die vom Fahrer bestätigte Lieferreihenfolge für ein - /// Fahrzeug am Backend. Wird ausschließlich beim Bestätigen-Button - /// in der Sortier-Page aufgerufen, nicht bei jedem Drag&Drop — - /// das lokale Reorder läuft weiterhin nur über ReorderService. - Future persistDeliveryOrder({ - required String carId, - required List orderedDeliveryIds, - }) async { - // STUB: Echte HTTP-Implementierung folgt, sobald der Endpoint steht. - await Future.delayed(const Duration(milliseconds: 200)); - debugPrint( - "ProcessRepository.persistDeliveryOrder (stub) — carId=$carId, " - "order=$orderedDeliveryIds", - ); - } - - /// Hebt die Zuordnung einer Lieferung zu einem Fahrzeug auf. Wird im - /// Auswahl-Schritt verwendet, wenn der Fahrer eine bereits seinem Auto - /// zugeordnete Lieferung wieder "freigibt", damit ein Kollege sie - /// übernehmen kann. - /// - /// TODO(backend): Echte HTTP-Implementierung sobald der DOCUcontrol- - /// bzw. VARIOcontrol-Endpoint zum Entfernen der Fahrzeug-Zuordnung - /// steht. Aktuell nur Stub mit Log, damit der UI-Flow getestet werden - /// kann; die Tour wird nach diesem Call NICHT automatisch refresht - /// (das übernimmt später der Stream nach dem echten Endpoint). - Future unassignDeliveryFromCar({ - required String deliveryId, - }) async { - // STUB: Echte HTTP-Implementierung folgt, sobald der Endpoint steht. - await Future.delayed(const Duration(milliseconds: 200)); - debugPrint( - "ProcessRepository.unassignDeliveryFromCar (stub) — " - "deliveryId=$deliveryId", - ); - } - - /// Meldet einen vom Fahrer dokumentierten Lieferungs-Abbruch (komplette - /// Lieferung) an das Backend mit Begründung. Wird zusätzlich zum - /// bestehenden `cancelDelivery`-Endpoint im [TourRepository] gefeuert, - /// damit auf Backend-Seite die Stornogründe getrennt geführt werden - /// können (Reporting / Logistik-Audit). - /// - /// TODO(backend): Echte HTTP-Implementierung sobald der DOCUcontrol- - /// Endpoint zum Berichten eines Abbruch-Grunds bereitsteht. Bis dahin - /// nur Log, damit der UI-Flow geübt werden kann. - Future reportDeliveryCancelled({ - required String deliveryId, - required String reason, - }) async { - // STUB: Echte HTTP-Implementierung folgt, sobald der Endpoint steht. - await Future.delayed(const Duration(milliseconds: 200)); - debugPrint( - "ProcessRepository.reportDeliveryCancelled (stub) — " - "deliveryId=$deliveryId, reason=$reason", - ); - } - - /// Meldet einen einzelnen Artikel oder eine Komponente als "heute nicht - /// auszuliefern" (Teilabbruch). Der Eintrag bleibt in der Lieferung - /// bestehen, wird aber im UI ausgegraut und vom Beladen-Fortschritt - /// ausgeschlossen. [componentId] wird genau dann gesetzt, wenn nur eine - /// einzelne Stücklisten-Position betroffen ist. - /// - /// TODO(backend): Echte HTTP-Implementierung sobald der DOCUcontrol-/ - /// VARIOcontrol-Endpoint für "Position zurückhalten" bereitsteht. Bis - /// dahin wird der Hold-State ausschließlich lokal in der jeweiligen - /// Beladen-Page gehalten (siehe `LoadingCustomerPage._heldKeys`). - Future reportItemHeld({ - required String deliveryId, - required String articleId, - String? componentId, - required String reason, - }) async { - // STUB: Echte HTTP-Implementierung folgt, sobald der Endpoint steht. - await Future.delayed(const Duration(milliseconds: 200)); - debugPrint( - "ProcessRepository.reportItemHeld (stub) — " - "deliveryId=$deliveryId, articleId=$articleId, " - "componentId=$componentId, reason=$reason", - ); - } -} diff --git a/lib/feature/delivery/repository/tour_repository.dart b/lib/feature/delivery/repository/tour_repository.dart deleted file mode 100644 index ddc63d8..0000000 --- a/lib/feature/delivery/repository/tour_repository.dart +++ /dev/null @@ -1,441 +0,0 @@ -import 'dart:typed_data'; - -import 'package:hl_lieferservice/dto/set_article_amount_response.dart'; -import 'package:hl_lieferservice/feature/delivery/service/tour_service.dart'; -import 'package:hl_lieferservice/model/delivery.dart'; -import 'package:hl_lieferservice/model/tour.dart'; -import 'package:rxdart/rxdart.dart'; - -import '../../../../dto/discount_add_response.dart'; -import '../../../../dto/discount_remove_response.dart'; -import '../../../../dto/discount_update_response.dart'; -import '../../../../model/article.dart'; -import '../detail/repository/note_repository.dart'; -import '../detail/service/notes_service.dart'; - -enum ScanResult { scanned, alreadyScanned, notFound } - -class TourNotFoundException implements Exception {} - -class TourRepository { - TourService service; - - final _tourStream = BehaviorSubject(); - final _paymentOptionsStream = BehaviorSubject>.seeded([]); - - Stream get tour => _tourStream.stream; - - Stream> get paymentOptions => _paymentOptionsStream.stream; - - TourRepository({required this.service}); - - Future loadTourOfToday(String userId) async { - _tourStream.add(await service.getTourOfToday(userId)); - } - - Future loadPaymentOptions() async { - _paymentOptionsStream.add( - (await service.getPaymentMethods()) - .map((option) => Payment.fromDTO(option)) - .toList(), - ); - } - - Future assignCar(String deliveryId, String carId) async { - await service.assignCar(deliveryId, carId); - - final tour = _tourStream.value!; - final index = tour.deliveries.indexWhere( - (delivery) => delivery.id == deliveryId, - ); - tour.deliveries[index].carId = carId; - - _tourStream.add(tour); - } - - Future scanArticle( - String deliveryId, - String carId, - String articleNumber, - ) async { - if (!_tourStream.hasValue) { - throw TourNotFoundException(); - } - - final tour = _tourStream.value!; - - if (tour.deliveries.any( - (delivery) => delivery.articles.any( - (article) => article.articleNumber == articleNumber, - ), - )) { - var delivery = tour.deliveries.firstWhere( - (delivery) => delivery.id == deliveryId, - ); - var article = delivery.articles.firstWhere( - (article) => article.articleNumber == articleNumber, - ); - - await service.scanArticle(article.internalId.toString()); - - if (article.scannedAmount < article.amount) { - article.scannedAmount += 1; - delivery.carId = carId; - await service.assignCar(deliveryId, carId); - _tourStream.add(tour); - return ScanResult.scanned; - } else { - return ScanResult.alreadyScanned; - } - } else { - return ScanResult.notFound; - } - } - - /// Scan a single BOM component locally. The server-side `scanArticle` call - /// for the parent article is deferred until **every** component of the - /// parent is fully scanned — only then does the parent count as loaded. - Future scanComponent( - String deliveryId, - String carId, - String componentArticleNumber, - ) async { - if (!_tourStream.hasValue) { - throw TourNotFoundException(); - } - - final tour = _tourStream.value!; - final delivery = tour.deliveries.firstWhere( - (d) => d.id == deliveryId, - ); - - // Locate the parent article and the matching component. - final parentArticle = delivery.findParentOfComponent( - componentArticleNumber, - ); - if (parentArticle == null) return ScanResult.notFound; - - final component = parentArticle.findComponent(componentArticleNumber)!; - - if (component.isFullyScanned) return ScanResult.alreadyScanned; - - // ── Local-only increment ── - component.scannedAmount += 1; - - // ── When every component is done, sync the parent with the server ── - if (parentArticle.isFullyScanned) { - await service.scanArticle(parentArticle.internalId.toString()); - parentArticle.scannedAmount += 1; - delivery.carId = carId; - await service.assignCar(deliveryId, carId); - } - - _tourStream.add(tour); - return ScanResult.scanned; - } - - Future unscan( - String deliveryId, - String articleId, - int newAmount, - String reason, - ) async { - if (!_tourStream.hasValue) { - throw TourNotFoundException(); - } - - Tour tour = _tourStream.value!; - String? noteId = await service.unscanArticle(articleId, newAmount, reason); - Article article = tour.deliveries - .firstWhere((delivery) => delivery.id == deliveryId) - .articles - .firstWhere((article) => article.internalId == int.parse(articleId)); - - article.removeNoteId = noteId; - article.scannedRemovedAmount += newAmount; - article.scannedAmount -= newAmount; - - _tourStream.add(tour); - } - - Future resetScan(String articleId, String deliveryId) async { - if (!_tourStream.hasValue) { - throw TourNotFoundException(); - } - - Tour tour = _tourStream.value!; - await service.resetScannedArticleAmount(articleId); - - Article article = tour.deliveries - .firstWhere((delivery) => delivery.id == deliveryId) - .articles - .firstWhere((article) => article.internalId == int.parse(articleId)); - - article.removeNoteId = null; - article.scannedRemovedAmount = 0; - article.scannedAmount = article.amount; - - _tourStream.add(tour); - } - - Future uploadDriverSignature( - String deliveryId, - Uint8List signature, - ) async { - NoteRepository noteRepository = NoteRepository(service: NoteService()); - await noteRepository.addNamedImage( - deliveryId, - signature, - "delivery_${deliveryId}_signature_driver.jpg", - ); - } - - Future uploadCustomerSignature( - String deliveryId, - Uint8List signature, - ) async { - NoteRepository noteRepository = NoteRepository(service: NoteService()); - await noteRepository.addNamedImage( - deliveryId, - signature, - "delivery_${deliveryId}_signature_customer.jpg", - ); - } - - Future addDiscount(String deliveryId, String reason, int value) async { - if (!_tourStream.hasValue) { - throw TourNotFoundException(); - } - - Tour tour = _tourStream.value!; - DiscountAddResponseDTO response = await service.addDiscount( - deliveryId, - value, - reason, - ); - - Delivery delivery = tour.deliveries.firstWhere( - (delivery) => delivery.id == deliveryId, - ); - Article discountArticle = Article.fromDTO(response.values.article); - delivery.totalNetValue = response.values.receipt.net; - delivery.totalGrossValue = response.values.receipt.gross; - - delivery.discount = Discount( - article: Article.fromDTO(response.values.article), - note: response.values.note.noteDescription, - noteId: response.values.note.rowId, - ); - delivery.articles = [ - ...delivery.articles, - discountArticle, - ]; - - _tourStream.add(tour); - } - - Future removeDiscount(String deliveryId) async { - if (!_tourStream.hasValue) { - throw TourNotFoundException(); - } - - Tour tour = _tourStream.value!; - DiscountRemoveResponseDTO response = await service.removeDiscount( - deliveryId, - ); - - Delivery delivery = tour.deliveries.firstWhere( - (delivery) => delivery.id == deliveryId, - ); - delivery.articles = - delivery.articles - .where( - (article) => - article.internalId != delivery.discount?.article.internalId, - ) - .toList(); - - delivery.discount = null; - delivery.totalGrossValue = response.receipt.gross; - delivery.totalNetValue = response.receipt.net; - - _tourStream.add(tour); - } - - Future updateDiscount( - String deliveryId, - String? reason, - int? value, - ) async { - if (!_tourStream.hasValue) { - throw TourNotFoundException(); - } - - Tour tour = _tourStream.value!; - DiscountUpdateResponseDTO response = await service.updateDiscount( - deliveryId, - reason, - value, - ); - - Delivery delivery = tour.deliveries.firstWhere( - (delivery) => delivery.id == deliveryId, - ); - if (response.values?.receipt != null) { - delivery.totalNetValue = response.values!.receipt.net; - delivery.totalGrossValue = response.values!.receipt.gross; - } - - String discountArticleNumber = delivery.discount!.article.articleNumber; - delivery.discount = Discount( - article: - response.values?.article != null - ? Article.fromDTO(response.values!.article) - : delivery.discount!.article, - note: - response.values?.note != null - ? response.values!.note.noteDescription - : delivery.discount!.note, - noteId: - response.values?.note != null - ? response.values!.note.rowId - : delivery.discount!.noteId, - ); - - delivery.articles = [ - ...delivery.articles.where( - (article) => article.articleNumber != discountArticleNumber, - ), - delivery.discount!.article, - ]; - - _tourStream.add(tour); - } - - Future reactivateDelivery(String deliveryId) async { - await _changeState(deliveryId, DeliveryState.ongoing); - } - - Future holdDelivery(String deliveryId) async { - await _changeState(deliveryId, DeliveryState.onhold); - } - - Future cancelDelivery(String deliveryId) async { - await _changeState(deliveryId, DeliveryState.canceled); - } - - Future finishDelivery(String deliveryId) async { - await _changeState(deliveryId, DeliveryState.finished); - Delivery delivery = _tourStream.value!.deliveries.firstWhere( - (delivery) => delivery.id == deliveryId, - ); - - await _updateDelivery(delivery); - await service.finishDelivery(deliveryId); - } - - Future setArticleAmount( - String deliveryId, - String articleId, - int amount, - String? reason, - ) async { - if (!_tourStream.hasValue) { - throw TourNotFoundException(); - } - - try { - SetArticleAmountResponseDTO dto = await service.setArticleAmount( - deliveryId, - articleId, - amount, - reason, - ); - - Delivery delivery = _tourStream.value!.deliveries.firstWhere( - (delivery) => delivery.id == deliveryId, - ); - Article article = delivery.articles.firstWhere( - (article) => article.internalId == int.parse(articleId), - ); - - article.amount = amount; - article.removeNoteId = dto.noteId; - _tourStream.add(_tourStream.value); - } catch (_) { - rethrow; - } - } - - Future _changeState(String deliveryId, DeliveryState state) async { - if (!_tourStream.hasValue) { - throw TourNotFoundException(); - } - - Tour tour = _tourStream.value!; - Delivery delivery = tour.deliveries.firstWhere( - (delivery) => delivery.id == deliveryId, - ); - - delivery.state = state; - await _updateDelivery(delivery); - - _tourStream.add(tour); - } - - Future _updateDelivery(Delivery newDelivery) async { - if (!_tourStream.hasValue) { - throw TourNotFoundException(); - } - - await service.updateDelivery(newDelivery); - } - - Future updatePayment(String deliveryId, Payment payment) async { - if (!_tourStream.hasValue) { - throw TourNotFoundException(); - } - - Tour tour = _tourStream.value!; - Delivery delivery = tour.deliveries.firstWhere( - (delivery) => delivery.id == deliveryId, - ); - - delivery.payment = payment; - await _updateDelivery(delivery); - - _tourStream.add(tour); - } - - Future updateOption( - String deliveryId, - String key, - dynamic value, - ) async { - if (!_tourStream.hasValue) { - throw TourNotFoundException(); - } - - Tour tour = _tourStream.value!; - Delivery delivery = tour.deliveries.firstWhere( - (delivery) => delivery.id == deliveryId, - ); - - delivery.options = - delivery.options.map((option) { - if (option.key == key) { - if (option.numerical) { - return option.copyWith(value: value); - } else { - return option.copyWith(value: value == true ? "1" : "0"); - } - } - - return option; - }).toList(); - - await _updateDelivery(delivery); - - _tourStream.add(tour); - } -} diff --git a/lib/feature/delivery/service/tour_service.dart b/lib/feature/delivery/service/tour_service.dart deleted file mode 100644 index bbb0564..0000000 --- a/lib/feature/delivery/service/tour_service.dart +++ /dev/null @@ -1,448 +0,0 @@ -import 'dart:convert'; -import 'dart:io'; - -import 'package:flutter/material.dart'; -import 'package:intl/intl.dart'; -import 'package:hl_lieferservice/dto/delivery_response.dart'; -import 'package:hl_lieferservice/dto/delivery_update.dart'; -import 'package:hl_lieferservice/dto/delivery_update_response.dart'; -import 'package:hl_lieferservice/dto/payment.dart'; -import 'package:hl_lieferservice/dto/payments.dart'; -import 'package:hl_lieferservice/dto/set_article_amount_request.dart'; -import 'package:hl_lieferservice/dto/set_article_amount_response.dart'; -import 'package:hl_lieferservice/model/car.dart'; -import 'package:hl_lieferservice/model/delivery.dart'; -import 'package:hl_lieferservice/model/tour.dart'; -import 'package:hl_lieferservice/util.dart'; -import 'package:http/http.dart'; - -import '../../../dto/basic_response.dart'; -import '../../../dto/discount_add_response.dart'; -import '../../../dto/discount_remove_response.dart'; -import '../../../dto/discount_update_response.dart'; -import '../../../dto/scan_response.dart'; -import '../../authentication/exceptions.dart'; - -class TourService { - TourService(); - - Future updateDelivery(Delivery delivery) async { - try { - var headers = {"Content-Type": "application/json"}; - headers.addAll(getSessionOrThrow()); - - debugPrint(getSessionOrThrow().toString()); - debugPrint(delivery.state.toString()); - debugPrint(jsonEncode(DeliveryUpdateDTO.fromEntity(delivery).toJson())); - - var response = await post( - urlBuilder("_web_updateDelivery"), - headers: headers, - body: jsonEncode(DeliveryUpdateDTO.fromEntity(delivery).toJson()), - ); - - if (response.statusCode == HttpStatus.unauthorized) { - throw UserUnauthorized(); - } - - debugPrint("BODY: ${response.body}"); - Map responseJson = jsonDecode(response.body); - DeliveryUpdateResponseDTO responseDto = - DeliveryUpdateResponseDTO.fromJson(responseJson); - - if (responseDto.code == "200") { - return; - } - - debugPrint("ERROR UPDATING:"); - debugPrint(responseDto.message); - } catch (e, st) { - debugPrint("ERROR WHILE UPDATING DELIVERY"); - debugPrint("$e"); - debugPrint(st.toString()); - - rethrow; - } - } - - Future assignCar(String deliveryId, String carId) async { - try { - var headers = {"Content-Type": "application/json"}; - headers.addAll(getSessionOrThrow()); - - var response = await post( - urlBuilder("_web_updateDelivery"), - headers: headers, - body: jsonEncode({"delivery_id": deliveryId, "car_id": carId}), - ); - - if (response.statusCode == HttpStatus.unauthorized) { - throw UserUnauthorized(); - } - - Map responseJson = jsonDecode(response.body); - DeliveryUpdateResponseDTO responseDto = - DeliveryUpdateResponseDTO.fromJson(responseJson); - - if (responseDto.code == "200") { - return; - } - - debugPrint("ERROR UPDATING:"); - debugPrint(responseDto.message); - } catch (e, st) { - debugPrint("ERROR WHILE UPDATING DELIVERY"); - debugPrint("$e"); - debugPrint(st.toString()); - - rethrow; - } - } - - /// List all available deliveries for today. - - Future getTourOfToday(String userId) async { - try { - var response = await post( - urlBuilder("_web_getDeliveries"), - headers: getSessionOrThrow(), - body: {"driver_id": userId, "date": getTodayDate()}, - ); - - if (response.statusCode == HttpStatus.unauthorized) { - throw UserUnauthorized(); - } - - DeliveryResponseDTO responseDto = DeliveryResponseDTO.fromJson( - jsonDecode(response.body), - ); - - return Tour( - discountArticleNumber: responseDto.discountArticleNumber, - date: DateTime.now(), - deliveries: responseDto.deliveries.map(Delivery.fromDTO).toList(), - paymentMethods: [], - driver: Driver( - cars: - responseDto.driver.cars - .map( - // Legacy: alte ERPframe-CarDto hat int-IDs, neue - // Domain-Entity erwartet UUID-Strings. Wir - // stringifizieren die int-ID und füllen - // accountId/active mit Stub-Werten — der ganze - // Service wird in Phase D entfernt. - (carDto) => Car( - id: carDto.id, - accountId: 0, - plate: carDto.plate, - active: true, - ), - ) - .toList(), - teamNumber: int.parse(responseDto.driver.id), - name: responseDto.driver.name, - salutation: responseDto.driver.salutation, - ), - ); - } catch (e, stacktrace) { - debugPrint(e.toString()); - debugPrint(stacktrace.toString()); - debugPrint("RANDOM EXCEPTION!"); - - rethrow; - } - } - - Future> getPaymentMethods() async { - try { - var response = await post( - urlBuilder("_web_getPaymentMethods"), - headers: getSessionOrThrow(), - body: {}, - ); - - if (response.statusCode == HttpStatus.unauthorized) { - throw UserUnauthorized(); - } - - Map responseJson = jsonDecode(response.body); - PaymentMethodListDTO responseDto = PaymentMethodListDTO.fromJson( - responseJson, - ); - - return responseDto.paymentMethods; - } catch (e, st) { - debugPrint("ERROR while retrieving allowed payment methods"); - debugPrint(e.toString()); - debugPrint(st.toString()); - - rethrow; - } - } - - Future unscanArticle( - String internalId, - int amount, - String reason, - ) async { - try { - var response = await post( - urlBuilder("_web_unscanArticle"), - headers: getSessionOrThrow(), - body: { - "article_id": internalId, - "amount": amount.toString(), - "reason": reason, - }, - ); - - if (response.statusCode == HttpStatus.unauthorized) { - throw UserUnauthorized(); - } - - Map responseJson = jsonDecode(response.body); - ScanResponseDTO responseDto = ScanResponseDTO.fromJson(responseJson); - - if (responseDto.succeeded == true) { - return responseDto.noteId; - } else { - throw responseDto.message; - } - } catch (e, st) { - debugPrint("ERROR WHILE REVERTING THE SCAN OF ARTICLE $internalId"); - debugPrint(e.toString()); - debugPrint(st.toString()); - - rethrow; - } - } - - Future resetScannedArticleAmount(String receiptRowId) async { - try { - var response = await post( - urlBuilder("_web_unscanArticleReset"), - headers: getSessionOrThrow(), - body: {"receipt_row_id": receiptRowId}, - ); - - if (response.statusCode == HttpStatus.unauthorized) { - throw UserUnauthorized(); - } - - Map responseJson = jsonDecode(response.body); - BasicResponseDTO responseDto = BasicResponseDTO.fromJson(responseJson); - - if (responseDto.succeeded == true) { - return; - } else { - throw responseDto.message; - } - } catch (e, st) { - debugPrint("ERROR WHILE REVERTING THE UNSCAN OF ARTICLE $receiptRowId"); - debugPrint(e.toString()); - debugPrint(st.toString()); - - rethrow; - } - } - - Future addDiscount( - String deliveryId, - int discount, - String note, - ) async { - try { - var response = await post( - urlBuilder("_web_addDiscount"), - headers: getSessionOrThrow(), - body: { - "delivery_id": deliveryId, - "discount": discount.toString(), - "note": note, - }, - ); - - if (response.statusCode == HttpStatus.unauthorized) { - throw UserUnauthorized(); - } - - Map responseJson = jsonDecode(response.body); - - // let it throw, if the values are invalid - return DiscountAddResponseDTO.fromJson(responseJson); - } catch (e, st) { - debugPrint("ERROR while adding discount"); - debugPrint(e.toString()); - debugPrint(st.toString()); - - rethrow; - } - } - - Future finishDelivery(String deliveryId) async { - try { - // ISO-8601 mit T-Separator: sprachunabhaengig fuer SQL-Server datetime. - // ('yyyy-MM-dd HH:mm:ss' OHNE T wird unter DATEFORMAT=DMY (DE) als YDM - // geparst und schlaegt fuer Tag > 12 fehl.) - // ERPframe arbeitet mit lokaler Zeit -> bewusst keine UTC-Konvertierung. - final String deliveredAt = DateFormat( - "yyyy-MM-dd'T'HH:mm:ss", - ).format(DateTime.now()); - - var headers = {"Content-Type": "application/json"}; - headers.addAll(getSessionOrThrow()); - - var response = await post( - urlBuilder("_web_finishDelivery"), - headers: headers, - body: jsonEncode({ - "delivery_id": deliveryId, - "delivered_at": deliveredAt, - }), - ); - - if (response.statusCode == HttpStatus.unauthorized) { - throw UserUnauthorized(); - } - - debugPrint("BODY: ${response.body}"); - Map responseJson = jsonDecode(response.body); - - // let it throw, if the values are invalid - return BasicResponseDTO.fromJson(responseJson); - } catch (e, st) { - debugPrint("ERROR while adding discount"); - debugPrint(e.toString()); - debugPrint(st.toString()); - - rethrow; - } - } - - Future setArticleAmount( - String deliveryId, - String articleId, - int amount, - String? reason, - ) async { - try { - var response = await post( - urlBuilder("_web_setArticleAmount"), - headers: {...getSessionOrThrow(), "Content-Type": "application/json"}, - body: jsonEncode( - SetArticleAmountRequestDTO( - articleId: articleId, - deliveryId: deliveryId, - amount: amount, - reason: reason, - ), - ), - ); - - if (response.statusCode == HttpStatus.unauthorized) { - throw UserUnauthorized(); - } - - debugPrint("BODY: ${response.body}"); - - Map responseJson = jsonDecode(response.body); - // let it throw, if the values are invalid - SetArticleAmountResponseDTO responseDto = - SetArticleAmountResponseDTO.fromJson(responseJson); - - if (!responseDto.succeeded) { - throw responseDto.message; - } else { - return responseDto; - } - } catch (e, st) { - debugPrint(e.toString()); - debugPrint(st.toString()); - - rethrow; - } - } - - Future removeDiscount(String deliveryId) async { - try { - var response = await post( - urlBuilder("_web_removeDiscount"), - headers: getSessionOrThrow(), - body: {"delivery_id": deliveryId}, - ); - - if (response.statusCode == HttpStatus.unauthorized) { - throw UserUnauthorized(); - } - - Map responseJson = jsonDecode(response.body); - - // let it throw, if the values are invalid - return DiscountRemoveResponseDTO.fromJson(responseJson); - } catch (e, st) { - debugPrint("ERROR while removing discount"); - debugPrint(e.toString()); - debugPrint(st.toString()); - - rethrow; - } - } - - Future updateDiscount( - String deliveryId, - String? note, - int? discount, - ) async { - try { - var response = await post( - urlBuilder("_web_updateDiscount"), - headers: getSessionOrThrow(), - body: {"delivery_id": deliveryId, "discount": discount, "note": note}, - ); - - if (response.statusCode == HttpStatus.unauthorized) { - throw UserUnauthorized(); - } - - Map responseJson = jsonDecode(response.body); - - // let it throw, if the values are invalid - return DiscountUpdateResponseDTO.fromJson(responseJson); - } catch (e, st) { - debugPrint("ERROR while retrieving allowed payment methods"); - debugPrint(e.toString()); - debugPrint(st.toString()); - - rethrow; - } - } - - Future scanArticle(String internalId) async { - try { - var response = await post( - urlBuilder("_web_scanArticle"), - headers: getSessionOrThrow(), - body: {"internal_id": internalId}, - ); - - debugPrint(jsonEncode({"internal_id": internalId})); - - if (response.statusCode == HttpStatus.unauthorized) { - throw UserUnauthorized(); - } - - Map responseJson = jsonDecode(response.body); - debugPrint(responseJson.toString()); - ScanResponseDTO responseDto = ScanResponseDTO.fromJson(responseJson); - - if (responseDto.succeeded == true) { - return; - } else { - debugPrint("ERROR: ${responseDto.message}"); - throw responseDto.message; - } - } catch (e) { - rethrow; - } - } -} diff --git a/lib/feature/delivery/util.dart b/lib/feature/delivery/util.dart deleted file mode 100644 index b07e0b4..0000000 --- a/lib/feature/delivery/util.dart +++ /dev/null @@ -1,22 +0,0 @@ -List reorderList(List old, int oldIndex, int newIndex) { - List tmp = [...old]; - - int newIndexCalc = newIndex - 1; - - if (newIndex < oldIndex) { - newIndexCalc = newIndex; - } - - if (newIndex == old.length) { - newIndexCalc = old.length - 1; - } - - if (newIndex == 0) { - newIndexCalc = 0; - } - - String oldItem = tmp.removeAt(oldIndex); - tmp.insert(newIndexCalc, oldItem); - - return tmp; -} diff --git a/lib/feature/feature_flags.dart b/lib/feature/feature_flags.dart new file mode 100644 index 0000000..db0bf55 --- /dev/null +++ b/lib/feature/feature_flags.dart @@ -0,0 +1,46 @@ +/// Globale, statische Feature-Schalter. +/// +/// Dient als Übergangs-Geländer während der Migration vom alten +/// ERPframe-Backend auf das neue Rust-Backend: Funktionen, die im neuen +/// Backend (noch) nicht modelliert sind — Rabatte, Zahlungsoptionen, +/// flexible Lieferoptionen, Preisanzeigen, Unterschriften-Upload — +/// werden hier gebündelt ausgeschaltet, statt sie in jedem UI-Widget +/// einzeln auszukommentieren. +/// +/// **Konvention**: jeder Flag bekommt einen kurzen Kommentar, *warum* +/// er gerade auf `false` steht und in welcher Phase der Migration +/// das gegebenenfalls wieder geöffnet wird. So bleibt nachvollziehbar, +/// was hier nur „pausiert" und nicht „weg" ist. +class FeatureFlags { + const FeatureFlags._(); + + /// Rabatt/Gutschrift-Funktion in der Detail-Ansicht. + /// Backend-Modell fehlt — nicht Teil der Logistik-Migration. Wird + /// frühestens nach C+D-2 wiedereröffnet, wenn überhaupt jemals. + static const bool discountsEnabled = false; + + /// Auswahl der Zahlungsart (Bar/EC/Vorkasse) am Ende der Lieferung. + /// Backend modelliert das nicht; die Logistik-App soll bewusst keinen + /// Zahlungs-Workflow tragen. + static const bool paymentsEnabled = false; + + /// Anzeige von Brutto-/Netto-Preisen und Vorauszahlung in der UI. + /// Backend liefert keine Preise — Logistik ≠ Buchhaltung. + static const bool pricesEnabled = false; + + /// Konfigurierbare Lieferoptionen (Treppe, Anschluss, Altgerät, …). + /// Backend-Schema noch nicht vorhanden; geplant für Phase E. + static const bool deliveryOptionsEnabled = false; + + /// Fahrer- und Kunden-Signatur beim Abschluss einer Lieferung. Verkabelt: + /// `SignatureView` → `CompleteDelivery` → multipart `/complete` (Signaturen + /// liegen lokal im Backend-Server). + static const bool signaturesEnabled = true; + + /// Eingabeart der Betrags-Gutschrift im Artikel-Step. + /// `false` → freies Betrags-Textfeld (Default); `true` → der ursprüngliche + /// +/−-Stepper (10-€-Schritte). Hinter dem Flag versteckt, falls der + /// Stepper wieder gewünscht wird. In beiden Fällen gilt die Backend-Regel: + /// >0, ≤150 €, Vielfaches von 10 €. + static const bool discountAmountStepper = false; +} diff --git a/lib/feature/loading/model/loading_group.dart b/lib/feature/loading/model/loading_group.dart deleted file mode 100644 index 0c616a4..0000000 --- a/lib/feature/loading/model/loading_group.dart +++ /dev/null @@ -1,115 +0,0 @@ -import 'package:hl_lieferservice/model/article.dart'; -import 'package:hl_lieferservice/model/delivery.dart'; - -/// Aggregiert eine [Delivery] mit ihren scannbaren Artikeln zu einer -/// Belade-Einheit. Bildet die Datenbasis für die Beladen-Phase (Vollbild- -/// Kunde + Übersicht). -/// -/// **Wichtige Geschäftslogik:** Für die Frage "ist diese Lieferung fertig -/// beladen?" zählen nur Artikel aus dem **Standardlager** (warehouseNr -/// `null` oder `"0"`). Außenlager-Artikel werden separat beim -/// Kundenbesuch in der Ausliefer-Phase abgeholt — sie blockieren also -/// nicht den Beladen-Abschluss. Konsequenz: alle Counter-Getter -/// ([totalArticles], [completeArticles], [scannedUnits], [totalUnits], -/// [isComplete], [isPartial], [hasAnyScanned]) ignorieren Außenlager- -/// Artikel. Wer alle Artikel braucht, greift direkt auf [articles] zu. -/// -/// Aufrufer füllen [articles] mit den scannbaren Artikeln dieser Lieferung -/// (also nicht alle Artikel der Lieferung — vorgefiltert über -/// `Article.scannable`). -class LoadingGroup { - /// Die zugrundeliegende Lieferung (inkl. Kunde, Adresse, State). - final Delivery delivery; - - /// Nummernschild des zugewiesenen Fahrzeugs zur Darstellung im Badge. - /// `null` wenn die Lieferung noch keinem Auto zugeordnet ist. - final String? carPlate; - - /// Die scannbaren Artikel der Lieferung (bereits vorgefiltert). - final List
articles; - - const LoadingGroup({ - required this.delivery, - required this.articles, - this.carPlate, - }); - - /// Alle Standardlager-Artikel (Lager-Nummer `null` oder `"0"`). Bildet - /// die Basis aller Beladen-Counter, weil Außenlager-Ware nicht in der - /// Belade-Halle scannbar ist. - List
get _standardArticles => articles - .where((a) => !_isExternalWarehouse(a.warehouseNr)) - .toList(growable: false); - - /// Anzahl der scannbaren Standardlager-Artikel. Parent-Artikel zählen - /// als 1 (nicht je Komponente). - int get totalArticles => _standardArticles.length; - - /// Anzahl der vollständig gescannten Standardlager-Artikel. Bei Parent- - /// Artikeln gilt "vollständig" = alle Komponenten vollständig. - int get completeArticles => - _standardArticles.where((a) => a.isFullyScanned).length; - - /// Gesamtanzahl der erwarteten Einzelstücke aus dem Standardlager — - /// bei Parent-Artikeln summiert über die Required-Amounts der - /// Komponenten. - int get totalUnits => _standardArticles.fold(0, (sum, a) { - if (a.isParent && a.components.isNotEmpty) { - return sum + a.components.fold(0, (s, c) => s + c.requiredAmount); - } - return sum + a.amount; - }); - - /// Bereits gescannte Einzelstücke aus dem Standardlager — analog zu - /// [totalUnits]. - int get scannedUnits => _standardArticles.fold(0, (sum, a) { - if (a.isParent && a.components.isNotEmpty) { - return sum + a.components.fold(0, (s, c) => s + c.scannedAmount); - } - return sum + a.scannedAmount + a.scannedRemovedAmount; - }); - - /// `true`, wenn alle Standardlager-Artikel vollständig gescannt sind. - /// - /// Edge-Case: Lieferung **ohne** Standardlager-Artikel (alle Artikel - /// liegen in Außenlagern) → automatisch fertig, weil in der Beladen- - /// Phase nichts zu tun ist. - bool get isComplete { - if (articles.isEmpty) return false; - if (_standardArticles.isEmpty) return true; - return completeArticles == totalArticles; - } - - /// `true`, wenn mindestens ein Stück gescannt wurde — egal ob Artikel - /// vollständig oder nicht. - bool get hasAnyScanned => scannedUnits > 0; - - /// `true`, wenn die Lieferung angefangen, aber nicht abgeschlossen wurde. - bool get isPartial => hasAnyScanned && !isComplete; - - /// `true`, wenn mindestens ein Artikel der Lieferung NICHT aus dem - /// Standard-Lager kommt. Standard-Lager hat die Nummer "0"; ein - /// `warehouseNr == null` interpretieren wir als "nicht angegeben" und - /// damit als Standard (kein False-Positive auf Datenlücken). - bool get hasExternalWarehouseArticles => - articles.any((a) => _isExternalWarehouse(a.warehouseNr)); - - /// Eindeutige Liste der Außenlager-Namen, die in dieser Lieferung - /// vorkommen — für Badges/Hinweise in der Übersicht. Wenn ein Artikel - /// nur eine `warehouseNr` aber keinen Namen hat, wird die Nummer als - /// Fallback genommen. - List get externalWarehouseLabels { - final labels = {}; - for (final a in articles) { - if (!_isExternalWarehouse(a.warehouseNr)) continue; - final label = (a.warehouseName?.isNotEmpty ?? false) - ? a.warehouseName! - : "Lager ${a.warehouseNr}"; - labels.add(label); - } - return labels.toList(growable: false); - } - - static bool _isExternalWarehouse(String? nr) => - nr != null && nr.isNotEmpty && nr != "0"; -} diff --git a/lib/feature/loading/presentation/loading_customer_page.dart b/lib/feature/loading/presentation/loading_customer_page.dart index 9891382..8377b15 100644 --- a/lib/feature/loading/presentation/loading_customer_page.dart +++ b/lib/feature/loading/presentation/loading_customer_page.dart @@ -1,766 +1,583 @@ -import 'dart:async'; - -import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'package:hl_lieferservice/domain/entity/article.dart'; +import 'package:hl_lieferservice/domain/entity/customer.dart'; +import 'package:hl_lieferservice/domain/entity/delivery.dart'; +import 'package:hl_lieferservice/domain/entity/delivery_item.dart'; +import 'package:hl_lieferservice/domain/entity/tour_details.dart'; +import 'package:hl_lieferservice/domain/entity/warehouse.dart'; import 'package:hl_lieferservice/feature/car_selection/bloc/bloc.dart'; import 'package:hl_lieferservice/feature/car_selection/bloc/state.dart'; -import 'package:hl_lieferservice/feature/delivery/bloc/phase_bloc.dart'; -import 'package:hl_lieferservice/feature/delivery/bloc/phase_event.dart'; +import 'package:hl_lieferservice/feature/cars/bloc/cars_bloc.dart'; +import 'package:hl_lieferservice/feature/cars/bloc/cars_state.dart'; import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.dart'; import 'package:hl_lieferservice/feature/delivery/bloc/tour_event.dart'; import 'package:hl_lieferservice/feature/delivery/bloc/tour_state.dart'; -import 'package:hl_lieferservice/feature/delivery/model/delivery_phase.dart'; -import 'package:hl_lieferservice/feature/delivery/overview/presentation/delivery_fail_page.dart'; -import 'package:hl_lieferservice/feature/loading/model/loading_group.dart'; -import 'package:hl_lieferservice/feature/loading/util/loading_order.dart'; -import 'package:hl_lieferservice/feature/loading/widget/article_row.dart'; -import 'package:hl_lieferservice/feature/loading/widget/hold_selection_dialog.dart'; -import 'package:hl_lieferservice/feature/loading/widget/reason_picker_dialog.dart'; -import 'package:hl_lieferservice/feature/scan/presentation/scanner.dart'; -import 'package:hl_lieferservice/feature/settings/bloc/settings_bloc.dart'; -import 'package:hl_lieferservice/feature/settings/bloc/settings_state.dart'; -import 'package:hl_lieferservice/model/article.dart'; -import 'package:hl_lieferservice/model/delivery.dart'; -import 'package:hl_lieferservice/model/tour.dart'; -import 'package:hl_lieferservice/widget/home/presentation/home_drawer.dart'; -import 'package:hl_lieferservice/widget/operations/bloc/operation_bloc.dart'; -import 'package:hl_lieferservice/widget/operations/bloc/operation_event.dart'; +import 'package:hl_lieferservice/feature/loading/widget/reason_catalog.dart'; +import 'package:hl_lieferservice/feature/loading/widget/reason_picker_sheet.dart'; +import 'package:hl_lieferservice/widget/scanner/article_scanner_stripe.dart'; +import 'package:hl_lieferservice/widget/scanner/item_matcher.dart'; +import 'package:hl_lieferservice/widget/scanner/manual_entry_dialog.dart'; +import 'package:hl_lieferservice/widget/scanner/scan_code_parser.dart'; -/// Detail-Ansicht für genau einen Kunden während der Beladen-Phase. +/// Vollbild-Sicht eines Kunden in der Beladen-Phase mit aktivem Scanner. /// -/// Aufgaben: -/// * Scanner-Widget oben — alle erkannten Barcodes werden direkt der -/// aktuellen Lieferung zugeordnet (keine Kunden-Auswahl mehr, da bereits -/// pro Kunde gefiltert). -/// * Übergangs-Dialog "Alle gescannt → Übersicht / Tour starten" pro -/// Kunde, einmalig. -/// * Aktions-Menü (im Customer-Header) zum Abbrechen / Zurückhalten von -/// Artikeln. -/// * Navigation: ein einziges Zurück-Symbol oben rechts in der AppBar -/// (führt per Pop zurück auf die Übersicht). Keine Pfeil-Navigation, -/// kein Phasen-Stepper. +/// Aufbau: +/// 1. **Scanner-Stripe** oben — *außerhalb* des PageView. Damit gibt es +/// nur eine einzige Kamera-Instanz im Page-Lebenszyklus; das Wischen +/// zwischen Kunden lässt den Stream nicht abreißen. (Würde der Stripe +/// pro PageView-Page neu instanziiert, käme er sich mit den von +/// PageView vorgeladenen Nachbarseiten ins Gehege und der Viewport +/// würde weiß.) +/// 2. **Zoom-Bar** direkt unter dem Viewport — `-` / Slider / `+` für +/// Daumenbedienung. Gebunden an `MobileScannerController.setZoomScale`. +/// 3. **PageView** mit Kundenkopf + Item-Liste pro Lieferung. /// -/// Hold-State: -/// Solange das Backend für `reportItemHeld` nur ein Stub ist, lebt der -/// Hold-Zustand ausschließlich im lokalen State dieses Widgets. Die UI -/// blendet betroffene Positionen entsprechend aus. Bei einem echten -/// Backend würde der Stream das Hold-Flag in der Delivery-Datenstruktur -/// mitliefern und dieser lokale Cache fiele weg. +/// Scan-Pipeline: +/// - Barcode = Artikelnummer. +/// - UI sucht in der **aktuell sichtbaren** Lieferung das erste nicht +/// fertig gescannte Item mit dieser Article-Nr und feuert `ScanItem`. +/// - Bloc inkrementiert lokal sofort, ruft das Backend, rollt bei +/// `rejected` zurück. class LoadingCustomerPage extends StatefulWidget { - const LoadingCustomerPage({ - super.key, - this.initialIndex = 0, - }); + const LoadingCustomerPage({super.key, this.initialIndex = 0}); - /// Index in der Beladereihenfolge, mit dem die Page öffnet. Wird typischer- - /// weise von der Overview-Page mit dem getappten Kunden-Index gesetzt. final int initialIndex; @override - State createState() => _LoadingCustomerPageState(); + State createState() => _LoadingCustomerPageState(); } class _LoadingCustomerPageState extends State { - /// Index des aktuell sichtbaren Kunden innerhalb der Beladereihenfolge. - late int _currentIndex; + late final PageController _pageController = + PageController(initialPage: widget.initialIndex); - /// Trackt Kunden, für die der "alle gescannt"-Dialog bereits gezeigt - /// wurde — verhindert erneutes Auftauchen beim Re-Besuch. - final Set _completedCustomersShown = {}; - - /// Hardware-Scanner-Buffer (analog zur alten ScanPage). - final FocusNode _focusNode = FocusNode(); - String _buffer = ''; - Timer? _bufferTimer; - - /// Lokaler Hold-Cache (siehe Klassen-Doc). Schlüssel über [HoldKey]. - /// Aufgeteilt nach Delivery-ID, damit beim Wechsel zwischen Kunden nichts - /// vermischt wird. - final Map> _heldKeys = >{}; - - /// Aktuell gewähltes Fahrzeug. Wird über den CarSelectBloc synchronisiert, - /// einmalig in initState bevor erster build. - String? _selectedCarId; - - /// Erkennt den Übergang "Lieferung läuft → abgeschlossen", damit der - /// Listener auch dann robust reagiert, wenn der TourBloc zwischendurch - /// rebuilded (z. B. wegen pendingScanRequests). - bool? _lastCompletionFlag; + /// Index der gerade sichtbaren Lieferung. Wird vom PageView gesetzt und + /// vom (lebenslang einen) Scanner für die Barcode-Auflösung benutzt. + int _currentIndex = 0; @override void initState() { super.initState(); _currentIndex = widget.initialIndex; - - WidgetsBinding.instance.addPostFrameCallback((_) => _focusNode.requestFocus()); - - final carState = context.read().state; - if (carState is CarSelectComplete) { - _selectedCarId = carState.selectedCar.id; - } } @override void dispose() { - _focusNode.dispose(); - _bufferTimer?.cancel(); + _pageController.dispose(); super.dispose(); } - // --------------------------------------------------------------------------- - // Scanner-Eingang - // --------------------------------------------------------------------------- + bool _multiCarTeam(BuildContext context) { + final state = context.read().state; + return state is CarsLoaded && state.cars.length >= 2; + } - void _handleKey(KeyEvent event) { - if (event is! KeyDownEvent) return; - if (event.logicalKey == LogicalKeyboardKey.enter) { - _bufferTimer?.cancel(); - if (_buffer.isNotEmpty) { - _handleBarcodeScanned(_buffer); - _buffer = ''; - } - } else { - final character = event.character; - if (character != null && character.isNotEmpty) { - _buffer += character; - _bufferTimer?.cancel(); - _bufferTimer = Timer(const Duration(milliseconds: 1000), () { - if (_buffer.isNotEmpty) { - _handleBarcodeScanned(_buffer); - _buffer = ''; - } - }); - } + List _ownInLoadingOrder( + BuildContext context, + TourDetails details, + String carId, + ) { + final relevant = _multiCarTeam(context) + ? details.deliveriesSorted + .where((d) => d.assignedCarId == carId) + .toList() + : details.deliveriesSorted; + return relevant.reversed.toList(); + } + + /// Verarbeitet einen Scan-Code (Kamera **oder** Manual-Entry). + /// + /// Validierung: das geparste Tripel **Artikelnummer × Kundennummer × + /// Belegnummer** muss *exakt* zur aktuell sichtbaren Lieferung passen. + /// Schon ein Mismatch in einer der drei Dimensionen → Snackbar + /// „nicht vorgesehen". Das schützt den Fahrer davor, versehentlich + /// einen QR-Code für eine **andere** Lieferung anzuwenden — die alte + /// Logik („alles, was die Artikelnummer kennt, wird gescannt") wäre + /// gegen Verwechslungen wehrlos. + /// + /// `customer.erpCustomerId` = `Kunden.Kundennummer` aus dem ERP-Sync + /// (entspricht der Kundennummer im QR-Mittelfeld). + void _onBarcode({ + required String code, + required List deliveries, + required TourDetails details, + required String carId, + }) { + if (deliveries.isEmpty) return; + final safeIndex = _currentIndex.clamp(0, deliveries.length - 1); + final delivery = deliveries[safeIndex]; + final customer = details.customerOf(delivery); + + final parsed = parseScanCode(code); + // Format-Fehler oder Konstellation passt nicht zur aktuell sichtbaren + // Lieferung → eindeutig „nicht vorgesehen". + if (parsed == null || + customer?.erpCustomerId != parsed.customerErpId || + delivery.erpBelegnummer != parsed.beleg) { + _showScanSnackbar(_notIntendedMessage); + return; + } + + final match = matchItem( + delivery: delivery, + details: details, + articleNumber: parsed.articleNumber, + ); + switch (match) { + case ItemMatchOk(:final item): + context + .read() + .add(ScanItem(deliveryItemId: item.id, actorCarId: carId)); + case ItemMatchNotInDelivery(): + _showScanSnackbar(_notIntendedMessage); + case ItemMatchNotScannable(): + _showScanSnackbar( + 'Diese Position ist nicht zum Scannen vorgesehen ' + '(Dienstleistung / Pauschale).', + ); + case ItemMatchAllDone(): + _showScanSnackbar('Diese Position ist bereits vollständig gescannt.'); + case ItemMatchAllRemoved(): + _showScanSnackbar('Diese Position wurde aus der Lieferung entfernt.'); + case ItemMatchNotOpen(): + _showScanSnackbar('Diese Position ist nicht (mehr) offen.'); } } - /// Extrahiert die Artikelnummer aus einem Barcode der Form - /// `;;`. Liefert null bei - /// ungültigem Format. Konsistent mit der Logik aus der alten ScanPage. - String? _extractArticleNumber(String barcode) { - debugPrint("QR CODE: $barcode"); - final parts = barcode.split(';'); - if (parts.length != 3) return null; - final articleNumber = parts[0].trim(); - if (articleNumber.isEmpty) return null; - return articleNumber; + static const String _notIntendedMessage = + 'Dieser Artikel ist für diese Lieferung nicht vorgesehen'; + + void _showScanSnackbar(String message) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + duration: const Duration(seconds: 2), + ), + ); } - void _handleBarcodeScanned(String barcode) { + Future _openManualEntry({ + required List deliveries, + required TourDetails details, + required String carId, + }) async { + final code = await showManualEntryDialog(context); + if (code == null || code.isEmpty) return; if (!mounted) return; - if (_selectedCarId == null) { - context.read().add( - FailOperation(message: "Kein Fahrzeug ausgewählt"), - ); - return; - } + _onBarcode( + code: code, + deliveries: deliveries, + details: details, + carId: carId, + ); + } - final articleNumber = _extractArticleNumber(barcode); - if (articleNumber == null) { - context.read().add( - FailOperation(message: "Ungültiger Barcode: $barcode"), - ); - return; - } + // ─── Delivery-Lifecycle-Aktionen ────────────────────────────────────── - final tourState = context.read().state; - if (tourState is! TourLoaded) return; + Future _onHoldDelivery(Delivery delivery) async { + final result = await showReasonPickerSheet( + context: context, + title: 'Lieferung pausieren', + presets: ReasonCatalog.deliveryHold, + confirmLabel: 'Pausieren', + ); + if (result == null || !mounted) return; + context + .read() + .add(HoldDelivery(deliveryId: delivery.id, reason: result.reason)); + } - // Wir richten den Scan immer an den aktuell sichtbaren Kunden — anders - // als in der alten ScanPage gibt es keine kundenübergreifende - // Disambiguierung mehr, weil die Page kundenfokussiert ist. - final groups = _buildLoadingGroups(tourState); - if (_currentIndex < 0 || _currentIndex >= groups.length) return; - final current = groups[_currentIndex]; - final delivery = current.delivery; + Future _onCancelDelivery(Delivery delivery) async { + // Cancel ist endgültig — Bestätigungsschritt vor dem Reason-Picker. + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Lieferung abbrechen?'), + content: const Text( + 'Eine abgebrochene Lieferung kann nicht wieder aktiviert werden.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('Zurück'), + ), + FilledButton( + style: FilledButton.styleFrom( + backgroundColor: Theme.of(ctx).colorScheme.error, + foregroundColor: Theme.of(ctx).colorScheme.onError, + ), + onPressed: () => Navigator.of(ctx).pop(true), + child: const Text('Weiter'), + ), + ], + ), + ); + if (confirmed != true || !mounted) return; + final result = await showReasonPickerSheet( + context: context, + title: 'Grund für Abbruch', + presets: ReasonCatalog.deliveryCancel, + confirmLabel: 'Lieferung abbrechen', + ); + if (result == null || !mounted) return; + context + .read() + .add(CancelDelivery(deliveryId: delivery.id, reason: result.reason)); + } + + Future _onResumeDelivery(Delivery delivery) async { + // Bei einer abgebrochenen Lieferung ist die Wiederherstellung + // semantisch riskanter als ein normales Hold-Resume — der Cancel + // wurde vom Fahrer aktiv bestätigt. Wir holen deshalb eine zweite + // Zustimmung ein, bevor wir das Resume feuern. Beim Hold-Resume + // sparen wir den Schritt: alltäglich und reversibel. if (delivery.state == DeliveryState.canceled) { - context.read().add( - FailOperation(message: "Lieferung wurde abgebrochen"), - ); - return; - } - - // 1) Komponenten-Match zuerst (Stückliste). - final parent = delivery.findParentOfComponent(articleNumber); - if (parent != null) { - final comp = parent.findComponent(articleNumber); - if (comp == null) return; - final heldSet = _heldKeys[delivery.id] ?? const {}; - if (heldSet.contains(HoldKey.component(parent, comp))) { - context.read().add( - FailOperation(message: "Komponente ist zurückgehalten"), - ); - return; - } - if (comp.isFullyScanned) { - context.read().add( - FailOperation(message: "Komponente bereits vollständig gescannt"), - ); - return; - } - context.read().add(ScanComponentEvent( - componentArticleNumber: articleNumber, - carId: _selectedCarId!.toString(), - deliveryId: delivery.id, - )); - return; - } - - // 2) Regulärer Artikel-Scan auf den aktuellen Kunden. - final article = delivery.articles.firstWhereOrNull( - (a) => a.articleNumber == articleNumber && !a.isParent, - ); - if (article == null) { - context.read().add( - FailOperation(message: "Artikel gehört nicht zu diesem Kunden"), - ); - return; - } - final heldSet = _heldKeys[delivery.id] ?? const {}; - if (heldSet.contains(HoldKey.article(article))) { - context.read().add( - FailOperation(message: "Artikel ist zurückgehalten"), - ); - return; - } - if (article.scannedAmount + article.scannedRemovedAmount >= - article.amount) { - context.read().add( - FailOperation(message: "Artikel bereits vollständig gescannt"), - ); - return; - } - context.read().add(ScanArticleEvent( - articleNumber: articleNumber, - carId: _selectedCarId!.toString(), - deliveryId: delivery.id, - )); - } - - // --------------------------------------------------------------------------- - // Datenaufbau - // --------------------------------------------------------------------------- - - String? _lookupCarPlate(String? carId, Tour tour) { - if (carId == null) return null; - return tour.driver.cars.firstWhereOrNull((c) => c.id == carId)?.plate; - } - - /// Beladereihenfolge inkl. abgebrochener Lieferungen für die UI-Anzeige - /// (sichtbar, ausgegraut). Pfeil-Navigation darf sie durchscrollen. - List _buildLoadingGroups(TourLoaded state) { - final carIdStr = _selectedCarId?.toString() ?? ""; - final orderedIds = LoadingOrder.computeForCar( - state: state, - carIdStr: carIdStr, - ); - - final byId = {for (final d in state.tour.deliveries) d.id: d}; - final groups = []; - for (final id in orderedIds) { - final delivery = byId[id]; - if (delivery == null) continue; - if (delivery.state == DeliveryState.finished) continue; - final scannable = - delivery.articles.where((a) => a.scannable).toList(growable: false); - if (scannable.isEmpty && delivery.state != DeliveryState.canceled) { - continue; - } - groups.add(LoadingGroup( - delivery: delivery, - articles: scannable, - carPlate: _lookupCarPlate(delivery.carId, state.tour), - )); - } - return groups; - } - - /// `true`, wenn die Artikel-Position aus dem Standardlager kommt - /// (warehouseNr `null` oder `"0"`). Außenlager-Artikel werden hier nicht - /// betrachtet, weil sie nicht in der Belade-Halle scannbar sind — der - /// Fahrer holt sie erst beim Kundenbesuch ab. - bool _isStandardWarehouse(Article a) { - final nr = a.warehouseNr; - return nr == null || nr.isEmpty || nr == "0"; - } - - /// Aktive Artikel = Standardlager-Artikel, die NICHT zurückgehalten - /// sind. Außenlager-Artikel werden grundsätzlich ausgeschlossen, weil - /// sie nicht in der Beladen-Phase scannbar sind. - List
_activeArticlesOf(LoadingGroup g) { - final held = _heldKeys[g.delivery.id] ?? const {}; - return g.articles.where((a) { - if (!_isStandardWarehouse(a)) return false; - if (held.contains(HoldKey.article(a))) return false; - // Komponenten-Hold deaktiviert den Artikel nicht komplett — wir - // werten in [_isLogicallyComplete] über die nicht-gehaltenen - // Komponenten aus. - return true; - }).toList(growable: false); - } - - /// `true`, wenn — unter Ausschluss der zurückgehaltenen Positionen UND - /// der Außenlager-Artikel — alle scannbaren Einheiten der Lieferung - /// gescannt sind. Berücksichtigt Komponenten-Holds individuell. - bool _isLogicallyComplete(LoadingGroup g) { - final held = _heldKeys[g.delivery.id] ?? const {}; - final standardArticles = - g.articles.where(_isStandardWarehouse).toList(growable: false); - - // Edge-Case 1: Lieferung hat überhaupt keine Standardlager-Artikel - // (alles extern) → in der Beladen-Phase nichts zu tun → fertig. - if (standardArticles.isEmpty) return g.articles.isNotEmpty; - - final actives = _activeArticlesOf(g); - if (actives.isEmpty) { - // Edge-Case 2: alle Standardlager-Artikel sind zurückgehalten → der - // Fahrer hat alles gemeldet, hier ist nichts mehr zu scannen. - return true; - } - for (final a in actives) { - if (a.isParent && a.components.isNotEmpty) { - for (final c in a.components) { - if (held.contains(HoldKey.component(a, c))) continue; - if (!c.isFullyScanned) return false; - } - } else { - if (!a.isFullyScanned) return false; - } - } - return true; - } - - /// Zähler-Tupel "x von y Artikeln gescannt" — bezieht sich auf das - /// Standardlager und ignoriert zurückgehaltene Positionen, damit der - /// Fortschritt für den Fahrer realistisch bleibt. - ({int done, int total}) _progressOf(LoadingGroup g) { - final held = _heldKeys[g.delivery.id] ?? const {}; - int done = 0; - int total = 0; - for (final a in g.articles) { - if (!_isStandardWarehouse(a)) continue; - if (a.isParent && a.components.isNotEmpty) { - for (final c in a.components) { - if (held.contains(HoldKey.component(a, c))) continue; - total += 1; - if (c.isFullyScanned) done += 1; - } - } else { - if (held.contains(HoldKey.article(a))) continue; - total += 1; - if (a.isFullyScanned) done += 1; - } - } - return (done: done, total: total); - } - - // --------------------------------------------------------------------------- - // Navigation - // --------------------------------------------------------------------------- - - void _openOverview() { - // Die Übersicht ist der Root-Render der Beladen-Phase (siehe home.dart). - // Aus dem Vollbild-Kunden kehrt der Fahrer deshalb per pop dorthin - // zurück — kein erneuter Push, damit der Stack flach bleibt. - Navigator.of(context).pop(); - } - - void _startTour() { - final carState = context.read().state; - if (carState is CarSelectComplete) { - context.read().add( - PhaseSet( - carId: carState.selectedCar.id.toString(), - phase: DeliveryPhase.ausliefern, - ), - ); - } - } - - // --------------------------------------------------------------------------- - // Dialoge & Aktionen - // --------------------------------------------------------------------------- - - Future _maybeShowCompletionDialog( - LoadingGroup current, - List allGroups, - ) async { - if (_completedCustomersShown.contains(current.delivery.id)) return; - - _completedCustomersShown.add(current.delivery.id); - - // "Tour starten"-Variante zeigen, wenn nach Abschluss dieses Kunden - // alle anderen aktiven Lieferungen ebenfalls fertig sind. Abgebrochene - // Lieferungen zählen nicht. Wir prüfen das auf Basis der gebauten - // Gruppen, weil _heldKeys lokal lebt und in _isLogicallyComplete - // berücksichtigt wird. - final allDone = allGroups.every((g) => - g.delivery.state == DeliveryState.canceled || - _isLogicallyComplete(g)); - - final navigator = Navigator.of(context, rootNavigator: true); - - // Wir warten einen Frame, damit der TourBloc-Listener zuende ist und - // dann der Dialog im stabilen UI-Zustand erscheint. - await Future.delayed(Duration.zero); - if (!mounted) return; - - if (allDone) { - final choice = await showDialog<_CompletionChoice>( - context: navigator.context, + final confirmed = await showDialog( + context: context, builder: (ctx) => AlertDialog( - title: const Text("Beladung abgeschlossen"), - content: const Text( - "Alle Lieferungen sind verladen. Tour jetzt starten?", - ), - actions: [ - TextButton( - onPressed: () => - Navigator.of(ctx).pop(_CompletionChoice.overview), - child: const Text("Übersicht"), - ), - FilledButton( - onPressed: () => - Navigator.of(ctx).pop(_CompletionChoice.startTour), - child: const Text("Tour starten"), - ), - ], - ), - ); - if (!mounted || choice == null) return; - switch (choice) { - case _CompletionChoice.overview: - _openOverview(); - break; - case _CompletionChoice.startTour: - _startTour(); - break; - } - } else { - final choice = await showDialog<_CompletionChoice>( - context: navigator.context, - builder: (ctx) => AlertDialog( - title: const Text("Alle Artikel gescannt"), + title: const Text('Lieferung wiederherstellen?'), content: Text( - "Alle Artikel für ${current.delivery.customer.name} wurden " - "gescannt. Zurück zur Übersicht?", + 'Die Lieferung wurde abgebrochen' + '${delivery.stateReason != null ? ' (Grund: ${delivery.stateReason})' : ''}.' + '\n\nWiederhergestellte Lieferungen erscheinen wieder in der ' + 'Beladen-Phase. Die ursprüngliche Cancel-Begründung wird dabei ' + 'gelöscht.', ), actions: [ TextButton( - onPressed: () => Navigator.of(ctx).pop(null), - child: const Text("Bleiben"), + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('Abbrechen'), ), FilledButton( - onPressed: () => - Navigator.of(ctx).pop(_CompletionChoice.overview), - child: const Text("Zur Übersicht"), + onPressed: () => Navigator.of(ctx).pop(true), + child: const Text('Wiederherstellen'), ), ], ), ); - if (!mounted || choice == null) return; - if (choice == _CompletionChoice.overview) { - _openOverview(); - } + if (confirmed != true || !mounted) return; + } + context.read().add(ResumeDelivery(deliveryId: delivery.id)); + } + + // ─── Item-Aktionen ──────────────────────────────────────────────────── + + /// Wird vom `PopupMenuButton` der jeweiligen Item-Row angeruft. Welche + /// Optionen verfügbar sind, entscheidet die Row selbst über den + /// aktuellen `ScanStatus` — hier landet nur die schon ausgewählte + /// Aktion zur Verarbeitung. + Future _onItemAction({ + required DeliveryItem item, + required _ItemAction action, + required String carId, + }) async { + switch (action) { + case _ItemAction.remove: + // Restmenge, die noch gutgeschrieben/entfernt werden kann. + final remaining = + item.requiredQuantity - item.scanProgress.creditedQuantity; + final result = await showReasonPickerSheet( + context: context, + title: 'Grund für das Entfernen', + presets: ReasonCatalog.itemRemove, + confirmLabel: 'Entfernen', + maxQuantity: remaining, + ); + if (result == null || !mounted) return; + context.read().add(RemoveItem( + deliveryItemId: item.id, + actorCarId: carId, + reason: result.reason, + quantity: result.quantity, + )); + case _ItemAction.unremove: + context.read().add(UnremoveItem( + deliveryItemId: item.id, + actorCarId: carId, + )); + case _ItemAction.manualConfirm: + // Fallback ohne Barcode: die ganze Restmenge manuell als geladen + // bestätigen. Bewusste Aussage → kurzer Bestätigungs-Dialog; das + // Backend protokolliert den Scan als `manual`. + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Manuell bestätigen'), + content: const Text( + 'Diese Position ohne Scan als vollständig geladen markieren? ' + 'Das wird als manuelle Bestätigung protokolliert.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('Abbrechen'), + ), + FilledButton( + onPressed: () => Navigator.of(ctx).pop(true), + child: const Text('Als geladen bestätigen'), + ), + ], + ), + ); + if (confirmed != true || !mounted) return; + context.read().add(ScanItem( + deliveryItemId: item.id, + actorCarId: carId, + manual: true, + )); } } - Future _cancelDeliveryFlow(LoadingGroup current) async { - final reason = await ReasonPickerDialog.show( - context, - title: "Lieferung abbrechen", - subtitle: current.delivery.customer.name, - ); - if (!mounted || reason == null) return; - - // CancelDeliveryEvent feuert den bestehenden tourRepository.cancelDelivery - // Aufruf. Parallel der ProcessRepository-Stub für die Audit-Spur des - // Grunds — bewusst nicht über den TourBloc geleitet, weil der Bloc den - // Grund aktuell nicht kennt und sich der Stub-Aufruf nicht in den Tour- - // Stream einklinkt. Sobald ein realer Endpoint existiert, kann das in - // einen erweiterten Event-Handler gewandert werden. - final tourBloc = context.read(); - tourBloc.add(CancelDeliveryEvent(deliveryId: current.delivery.id)); - - final processRepository = tourBloc.processRepository; - unawaited(processRepository.reportDeliveryCancelled( - deliveryId: current.delivery.id, - reason: reason, - )); - } - - Future _holdItemsFlow(LoadingGroup current) async { - final alreadyHeld = - _heldKeys[current.delivery.id] ?? const {}; - final selected = await HoldSelectionDialog.show( - context, - customerName: current.delivery.customer.name, - articles: current.articles, - alreadyHeld: alreadyHeld, - ); - if (!mounted || selected == null || selected.isEmpty) return; - - final reason = await ReasonPickerDialog.show( - context, - title: "Artikel zurückhalten", - subtitle: - "${selected.length} Position(en) für ${current.delivery.customer.name}", - ); - if (!mounted || reason == null) return; - - final processRepository = context.read().processRepository; - final newHeld = {...alreadyHeld}; - for (final item in selected) { - unawaited(processRepository.reportItemHeld( - deliveryId: current.delivery.id, - articleId: item.article.internalId.toString(), - componentId: item.component?.articleNumber, - reason: reason, - )); - newHeld.add(item.key); - } - setState(() { - _heldKeys[current.delivery.id] = newHeld; - // Hold-Status kann logische Vollständigkeit auslösen → Erkennung neu. - _lastCompletionFlag = null; - }); - } - - // --------------------------------------------------------------------------- - // UI - // --------------------------------------------------------------------------- - @override Widget build(BuildContext context) { - return BlocConsumer( - listener: (context, carState) { - if (carState is CarSelectComplete) { - setState(() => _selectedCarId = carState.selectedCar.id); - } - }, + return BlocBuilder( builder: (context, carState) { - return BlocConsumer( - listener: (context, tourState) { - if (tourState is! TourLoaded) return; - final groups = _buildLoadingGroups(tourState); - if (_currentIndex >= groups.length && groups.isNotEmpty) { - _currentIndex = groups.length - 1; - } - if (groups.isEmpty) return; - - final current = groups[_currentIndex.clamp(0, groups.length - 1)]; - if (current.delivery.state == DeliveryState.canceled) { - _lastCompletionFlag = null; - return; - } - - final isComplete = _isLogicallyComplete(current); - // Übergang false → true erkannt → Dialog (einmalig pro Kunde). - if (_lastCompletionFlag == false && isComplete) { - _maybeShowCompletionDialog(current, groups); - } - _lastCompletionFlag = isComplete; - }, + final carId = + carState is CarSelectComplete ? carState.selectedCar.id : ''; + return BlocBuilder( builder: (context, tourState) { - if (tourState is TourLoadingFailed) { - return const DeliveryLoadingFailedPage(); - } if (tourState is! TourLoaded) { return const Scaffold( body: Center(child: CircularProgressIndicator()), ); } + final deliveries = + _ownInLoadingOrder(context, tourState.details, carId); + if (deliveries.isEmpty) { + return Scaffold( + appBar: AppBar(title: const Text('Beladung')), + body: const Center( + child: Text('Keine Lieferungen zugewiesen.'), + ), + ); + } - final settingsState = context.read().state; - final useHardwareScanner = settingsState is AppSettingsLoaded && - settingsState.settings.useHardwareScanner; - - final groups = _buildLoadingGroups(tourState); - return _buildScaffold( - tourState: tourState, - groups: groups, - useHardwareScanner: useHardwareScanner, + return Scaffold( + // AppBar bewusst schlicht — die Lieferungs-Lifecycle-Aktionen + // (Pausieren / Abbrechen / Wiederherstellen) sind in den + // Kunden-Header gewandert, vertikal mittig rechts. So + // hat der Fahrer den Bezug „Aktion betrifft DIESEN Kunden" + // direkt vor Augen. + // + // Farbe primary statt M3-Default `surface`: die Loading- + // Customer-Page ist eine Sub-Page der LoadingOverviewPage, + // deren `PhaseStepper`-AppBar primary ist. Eine weiße + // AppBar hier bricht den Flow visuell — primary stellt die + // Konsistenz wieder her. + appBar: AppBar( + title: const Text('Beladung'), + backgroundColor: Theme.of(context).colorScheme.primary, + foregroundColor: Theme.of(context).colorScheme.onPrimary, + ), + body: Column( + children: [ + ArticleScannerStripe( + onBarcode: (code) => _onBarcode( + code: code, + deliveries: deliveries, + details: tourState.details, + carId: carId, + ), + onManualEntry: () => _openManualEntry( + deliveries: deliveries, + details: tourState.details, + carId: carId, + ), + ), + Expanded( + child: PageView.builder( + controller: _pageController, + onPageChanged: (i) => + setState(() => _currentIndex = i), + itemCount: deliveries.length, + itemBuilder: (context, index) { + final delivery = deliveries[index]; + return _CustomerBody( + delivery: delivery, + details: tourState.details, + position: index + 1, + totalCount: deliveries.length, + onItemAction: (item, action) => _onItemAction( + item: item, + action: action, + carId: carId, + ), + onHoldDelivery: () => _onHoldDelivery(delivery), + onCancelDelivery: () => _onCancelDelivery(delivery), + onResumeDelivery: () => _onResumeDelivery(delivery), + ); + }, + ), + ), + ], + ), ); }, ); }, ); } +} - Widget _buildScaffold({ - required TourLoaded tourState, - required List groups, - required bool useHardwareScanner, - }) { - final hasGroups = groups.isNotEmpty; - final safeIndex = hasGroups ? _currentIndex.clamp(0, groups.length - 1) : 0; - final current = hasGroups ? groups[safeIndex] : null; - final isCanceled = current?.delivery.state == DeliveryState.canceled; +class _CustomerBody extends StatelessWidget { + const _CustomerBody({ + required this.delivery, + required this.details, + required this.position, + required this.totalCount, + required this.onItemAction, + required this.onHoldDelivery, + required this.onCancelDelivery, + required this.onResumeDelivery, + }); - final theme = Theme.of(context); - return Scaffold( - drawer: const HomeAppDrawer(), - appBar: AppBar( - backgroundColor: theme.primaryColor, - foregroundColor: theme.colorScheme.onPrimary, - leading: IconButton( - icon: const Icon(Icons.arrow_back), - tooltip: "Zurück", - onPressed: () => Navigator.of(context).pop(), - ), - title: const Text( - "Lieferdetails", - style: TextStyle(fontWeight: FontWeight.w600), - ), - actions: const [ - _AppBarPlateBadge(), - SizedBox(width: 8), - ], - ), - body: KeyboardListener( - focusNode: _focusNode, - onKeyEvent: _handleKey, - child: SafeArea( - top: false, - child: !hasGroups - ? const _EmptyState() - : _buildCustomerView( - tourState: tourState, - groups: groups, - safeIndex: safeIndex, - current: current!, - isCanceled: isCanceled, - useHardwareScanner: useHardwareScanner, - ), - ), - ), - ); - } + final Delivery delivery; + final TourDetails details; - Widget _buildCustomerView({ - required TourLoaded tourState, - required List groups, - required int safeIndex, - required LoadingGroup current, - required bool isCanceled, - required bool useHardwareScanner, - }) { - final progress = _progressOf(current); - final heldSet = _heldKeys[current.delivery.id] ?? const {}; - final theme = Theme.of(context); + /// 1-basierte Position dieser Lieferung in der Belade-Reihenfolge. + final int position; + + /// Gesamtanzahl der Lieferungen — für „X von Y". + final int totalCount; + + final void Function(DeliveryItem item, _ItemAction action) onItemAction; + final VoidCallback onHoldDelivery; + final VoidCallback onCancelDelivery; + final VoidCallback onResumeDelivery; + + @override + Widget build(BuildContext context) { + final customer = details.customerOf(delivery); + // Items werden vom Aggregat-Helper schon nach Lager gruppiert + // geliefert: Standardlager zuerst, danach Filiale alphabetisch. + // Nicht-scanbare Positionen und `removed`-Items sind dabei schon + // ausgefiltert. + final groups = details.itemsGroupedByWarehouse(delivery); + + // Nicht-scanbare Positionen (Dienstleistung / Pauschale / Fracht) — die + // werden NICHT beladen/gescannt, sollen aber sichtbar sein, damit der + // Fahrer auch eine reine Dienstleistungs-Lieferung als „echte Anfahrt" + // erkennt. Liegen außerhalb von `groups` (die nur scanbare Items führen). + final serviceItems = details.nonScannableItems(delivery).toList(); return Column( children: [ - if (tourState.pendingScanRequests > 0) const LinearProgressIndicator(), - _ScannerSlot( - isCanceled: isCanceled, - useHardwareScanner: useHardwareScanner, - onBarcode: _handleBarcodeScanned, - ), - _CustomerHeader( - position: safeIndex + 1, - total: groups.length, - current: current, - progress: progress, - isCanceled: isCanceled, - actionMenuBuilder: (ctx) => _buildAppBarMenu(ctx, current), - ), - const Divider(height: 1), - Expanded( - child: Opacity( - opacity: isCanceled ? 0.45 : 1.0, - child: ListView( - padding: const EdgeInsets.only(top: 4, bottom: 16), - children: [ - for (final section in _groupArticlesByWarehouse(current.articles)) ...[ - _WarehouseSectionHeader( - label: section.label, - isExternal: section.isExternal, - ), - for (final article in section.articles) - ArticleRow( - article: article, - isHeld: heldSet.contains(HoldKey.article(article)), - disabled: isCanceled, - heldComponents: heldSet, - ), - ], - if (isCanceled) - Padding( - padding: const EdgeInsets.all(16), - child: Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.red.withValues(alpha: 0.06), - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: Colors.red.withValues(alpha: 0.3)), - ), - child: Row( - children: [ - const Icon(Icons.cancel_outlined, color: Colors.red), - const SizedBox(width: 12), - Expanded( - child: Text( - "Diese Lieferung wurde abgebrochen und wird " - "heute nicht ausgeliefert.", - style: theme.textTheme.bodyMedium, - ), + // Header bekommt einen eigenen, leicht abgehobenen Hintergrund + // aus dem Material-3-Surface-Stack (`surfaceContainerHigh`). + // Das hebt den Kunden-/Lieferungs-Block visuell vom Item-Body + // ab — Dark-Mode-tauglich, ohne dass eine Hardcodierte Farbe + // bei einem späteren Theme-Wechsel verkehrt aussieht. + Container( + width: double.infinity, + color: Theme.of(context).colorScheme.surfaceContainerHigh, + padding: const EdgeInsets.fromLTRB(16, 16, 4, 16), + // Vertikal zentriert: Avatar links, Kunden-/Lieferungs-Info in + // der Mitte, Aktions-Menü ganz rechts (statt wie früher im + // AppBar oben). Der Bezug „diese Aktion betrifft genau DIESEN + // Kunden" wird dadurch räumlich klar. + child: Row( + children: [ + _CustomerAvatar(customer: customer), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Lieferung $position von $totalCount', + style: Theme.of(context) + .textTheme + .labelMedium + ?.copyWith( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.w600, + letterSpacing: 0.3, ), - ], - ), ), + const SizedBox(height: 2), + Text( + customer?.name ?? '⟨Unbekannter Kunde⟩', + style: Theme.of(context).textTheme.headlineSmall, + ), + const SizedBox(height: 4), + Text( + delivery.deliveryAddressSnapshot.oneLine, + style: Theme.of(context).textTheme.bodyMedium, + ), + if (delivery.state != DeliveryState.active) ...[ + const SizedBox(height: 8), + _DeliveryStateBadge(delivery: delivery), + ], + ], + ), + ), + _DeliveryActionsMenu( + delivery: delivery, + onHold: onHoldDelivery, + onCancel: onCancelDelivery, + onResume: onResumeDelivery, + ), + ], + ), + ), + // Kein expliziter Divider mehr — der farbige Header-Block trennt + // sich schon visuell vom Item-Body. + Expanded( + // Eine einzige Scroll-Liste: zuerst die scanbaren Items je Lager + // (Standardlager, dann Filiale), darunter — sofern vorhanden — die + // gebuchten Dienstleistungen (nicht-scanbare Positionen). Die + // Dienstleistungen werden GENAU SO wie Standardlager-Artikel + // dargestellt, nur mit dem Hinweis „kein Scanvorgang notwendig". So + // wirkt auch eine reine Dienstleistungs-Lieferung nicht leer. + // + // Wenig Items pro Lieferung → `ListView` mit children-Liste reicht + // performant aus und ist lesbarer als ein Index-Builder. + // + // Bottom-Inset: kein Bottom-Bar in dieser Page → die System- + // Navigationsleiste selbst freihalten. + child: ListView( + padding: EdgeInsets.fromLTRB( + 0, + 4, + 0, + 4 + MediaQuery.viewPaddingOf(context).bottom, + ), + children: [ + // Nur wenn es WEDER scanbare Ware NOCH eine Dienstleistung gibt, + // ist wirklich nichts zu tun. + if (groups.isEmpty && serviceItems.isEmpty) + const _NothingToLoadHint(), + for (final group in groups) ...[ + _WarehouseSectionHeader( + warehouse: group.warehouse, + items: group.items, + ), + for (final item in _parentFirst(group.items)) + _ItemRow( + item: item, + details: details, + onAction: (action) => onItemAction(item, action), ), ], - ), - ), - ), - ], - ); - } - - PopupMenuButton<_MenuAction> _buildAppBarMenu( - BuildContext context, - LoadingGroup current, - ) { - return PopupMenuButton<_MenuAction>( - icon: const Icon(Icons.more_vert), - tooltip: "Weitere Aktionen", - onSelected: (action) async { - switch (action) { - case _MenuAction.cancel: - await _cancelDeliveryFlow(current); - break; - case _MenuAction.hold: - await _holdItemsFlow(current); - break; - } - }, - itemBuilder: (ctx) => [ - PopupMenuItem( - value: _MenuAction.hold, - enabled: current.delivery.state != DeliveryState.canceled, - child: const ListTile( - dense: true, - contentPadding: EdgeInsets.zero, - leading: Icon(Icons.pause_circle_outline), - title: Text("Artikel nicht heute liefern"), - ), - ), - PopupMenuItem( - value: _MenuAction.cancel, - enabled: current.delivery.state != DeliveryState.canceled, - child: const ListTile( - dense: true, - contentPadding: EdgeInsets.zero, - leading: Icon(Icons.cancel_outlined, color: Colors.red), - title: Text( - "Lieferung komplett abbrechen", - style: TextStyle(color: Colors.red), - ), + // Gebuchte Dienstleistungen (nicht-scanbare Positionen): eigene + // Zwischenüberschrift „Dienstleistungen" (optisch wie + // Standardlager) und dieselbe Item-Card wie scanbare Artikel — + // einziger Unterschied ist der Hinweis, dass kein Scanvorgang + // nötig ist (`scanNotRequired`). + if (serviceItems.isNotEmpty) ...[ + const _ServiceSectionHeader(), + for (final item in _parentFirst(serviceItems)) + _ItemRow( + item: item, + details: details, + onAction: (action) => onItemAction(item, action), + scanNotRequired: true, + ), + ], + ], ), ), ], @@ -768,308 +585,93 @@ class _LoadingCustomerPageState extends State { } } -// --------------------------------------------------------------------------- -// Helper-Widgets -// --------------------------------------------------------------------------- - -class _EmptyState extends StatelessWidget { - const _EmptyState(); - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.inbox_outlined, size: 64, color: scheme.onSurfaceVariant), - const SizedBox(height: 12), - Text( - "Keine Lieferungen zum Beladen", - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: 6), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 32), - child: Text( - "Für das ausgewählte Fahrzeug ist die Beladereihenfolge leer.", - textAlign: TextAlign.center, - style: TextStyle(color: scheme.onSurfaceVariant), - ), - ), - ], - ), - ); - } -} - -class _ScannerSlot extends StatelessWidget { - const _ScannerSlot({ - required this.isCanceled, - required this.useHardwareScanner, - required this.onBarcode, - }); - - final bool isCanceled; - final bool useHardwareScanner; - final void Function(String) onBarcode; - - @override - Widget build(BuildContext context) { - if (isCanceled) { - return Container( - height: 110, - margin: const EdgeInsets.fromLTRB(12, 8, 12, 4), - decoration: BoxDecoration( - color: Colors.grey.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Colors.grey.withValues(alpha: 0.4)), - ), - child: const Center( - child: Padding( - padding: EdgeInsets.symmetric(horizontal: 16), - child: Text( - "Diese Lieferung wurde abgebrochen.", - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: Colors.black54), - textAlign: TextAlign.center, - ), - ), - ), - ); - } - if (useHardwareScanner) { - // Hardware-Scanner liefert Eingaben über den KeyboardListener. - // Wir zeigen einen kompakten Hinweis-Bereich statt der Kamera. - return Container( - height: 60, - margin: const EdgeInsets.fromLTRB(12, 8, 12, 4), - alignment: Alignment.center, - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(8), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: const [ - Icon(Icons.qr_code_scanner_outlined, size: 18), - SizedBox(width: 8), - Text("Bereit für Hardware-Scan"), - ], - ), - ); - } - return Padding( - padding: const EdgeInsets.fromLTRB(8, 4, 8, 4), - child: BarcodeScannerWidget(onBarcodeDetected: onBarcode), - ); - } -} - -class _CustomerHeader extends StatelessWidget { - const _CustomerHeader({ - required this.position, - required this.total, - required this.current, - required this.progress, - required this.isCanceled, - required this.actionMenuBuilder, - }); - - final int position; - final int total; - final LoadingGroup current; - final ({int done, int total}) progress; - final bool isCanceled; - final Widget Function(BuildContext) actionMenuBuilder; +/// Sektions-Kopf vor den gebuchten Dienstleistungen (nicht-scanbare +/// Positionen) in der Beladen-Ansicht. Bewusst optisch identisch zum +/// `_WarehouseSectionHeader` („Standardlager"): gleiche Maße, gleiche +/// neutrale Farbe — die Dienstleistungen reihen sich damit nahtlos in die +/// Lager-Gliederung ein. +/// +/// Anders als der Lager-Kopf trägt dieser KEINEN „fertig/gesamt"-Zähler +/// rechts: Dienstleistungen werden nicht gescannt, ein Fortschrittszähler +/// wäre irreführend. Stattdessen sagt der Subtitle direkt, dass hier kein +/// Scanvorgang nötig ist. +class _ServiceSectionHeader extends StatelessWidget { + const _ServiceSectionHeader(); @override Widget build(BuildContext context) { final theme = Theme.of(context); - final color = isCanceled ? Colors.red.shade400 : theme.colorScheme.primary; + final color = theme.colorScheme.onSurfaceVariant; return Padding( - padding: const EdgeInsets.fromLTRB(16, 6, 4, 8), + padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), child: Row( - crossAxisAlignment: CrossAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, children: [ - CircleAvatar( - backgroundColor: color, - foregroundColor: theme.colorScheme.onPrimary, - child: Text( - "$position", - style: const TextStyle(fontWeight: FontWeight.bold), - ), - ), - const SizedBox(width: 12), + Icon(Icons.handyman_outlined, size: 18, color: color), + const SizedBox(width: 6), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, children: [ - Row( - children: [ - Expanded( - child: Text( - current.delivery.customer.name, - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - decoration: isCanceled - ? TextDecoration.lineThrough - : TextDecoration.none, - ), - ), - ), - Text( - "Kunde $position/$total", - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ], - ), - const SizedBox(height: 2), Text( - current.delivery.customer.address.toString(), - style: TextStyle( - fontSize: 12, - color: theme.colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 6), - Text( - isCanceled - ? "Lieferung abgebrochen" - : "${progress.done}/${progress.total} Artikel gescannt", + 'Dienstleistungen', style: TextStyle( fontSize: 13, - fontWeight: FontWeight.w600, - color: isCanceled - ? Colors.red.shade700 - : (progress.done == progress.total && progress.total > 0 - ? Colors.green.shade700 - : theme.colorScheme.onSurface), + fontWeight: FontWeight.bold, + color: color, + letterSpacing: 0.4, + ), + ), + Text( + 'Kein Scanvorgang notwendig', + style: TextStyle( + fontSize: 11, + color: theme.colorScheme.onSurfaceVariant, ), ), ], ), ), - actionMenuBuilder(context), ], ), ); } } -/// Eine Lager-Sektion in der Artikel-Liste — Header + zugehörige Artikel. -class _WarehouseSection { - const _WarehouseSection({ - required this.label, - required this.isExternal, - required this.articles, - }); - - final String label; - final bool isExternal; - final List
articles; -} - -/// Gruppiert Artikel nach Lager. Standardlager (Nummer "0" oder leer/null) -/// landet IMMER an erster Stelle — auch wenn keine Artikel dort liegen, -/// taucht der Header eines aktiven Außenlagers darunter konsistent -/// auf. Außenlager folgen alphabetisch nach Label. -List<_WarehouseSection> _groupArticlesByWarehouse(List
articles) { - const standardKey = "_STD"; - final Map> byKey = {}; - final Map labels = {}; - - bool isExternal(String? nr) => - nr != null && nr.isNotEmpty && nr != "0"; - - for (final a in articles) { - final external = isExternal(a.warehouseNr); - final key = external ? a.warehouseNr! : standardKey; - final label = external - ? ((a.warehouseName?.isNotEmpty ?? false) - ? a.warehouseName! - : "Lager ${a.warehouseNr}") - : "Standardlager"; - byKey.putIfAbsent(key, () =>
[]).add(a); - labels[key] = label; - } - - final keys = byKey.keys.toList(); - keys.sort((a, b) { - // Standardlager IMMER ganz oben. - if (a == standardKey) return -1; - if (b == standardKey) return 1; - return labels[a]!.compareTo(labels[b]!); - }); - - return [ - for (final k in keys) - _WarehouseSection( - label: labels[k]!, - isExternal: k != standardKey, - articles: byKey[k]!, - ), - ]; -} - -/// Voller-Breite-Header über einer Lager-Sektion. Standardlager neutral, -/// Außenlager in deutlichem Orange-Akzent — damit der Fahrer beim Scrollen -/// sofort sieht, wo er noch hinfahren muss. -class _WarehouseSectionHeader extends StatelessWidget { - const _WarehouseSectionHeader({ - required this.label, - required this.isExternal, - }); - - final String label; - final bool isExternal; +/// Vollbreiter Hinweis am unteren Rand einer Dienstleistungs-Card: diese +/// Position wird NICHT gescannt/beladen. Sitzt an genau der Stelle, an der +/// bei scanbaren Artikeln der „Manuell als geladen bestätigen"-Button steht +/// (der bei nicht-scanbaren Positionen entfällt) — so bleibt die Card-Geometrie +/// identisch zu den Standardlager-Artikeln, nur die Aussage ist eine andere. +class _ScanNotRequiredHint extends StatelessWidget { + const _ScanNotRequiredHint(); @override Widget build(BuildContext context) { final theme = Theme.of(context); - final Color bg; - final Color fg; - final IconData icon; - - if (isExternal) { - bg = Colors.deepOrange.withValues(alpha: 0.15); - fg = Colors.deepOrange.shade800; - icon = Icons.warehouse_outlined; - } else { - bg = theme.colorScheme.surfaceContainerHighest; - fg = theme.colorScheme.onSurfaceVariant; - icon = Icons.home_work_outlined; - } + final color = theme.colorScheme.onSurfaceVariant; return Container( - margin: const EdgeInsets.fromLTRB(0, 8, 0, 4), - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( - color: bg, - border: isExternal - ? Border( - left: BorderSide(color: Colors.deepOrange.shade700, width: 4), - ) - : null, + color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(8), ), child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(icon, size: 18, color: fg), - const SizedBox(width: 8), - Expanded( + Icon(Icons.info_outline, size: 16, color: color), + const SizedBox(width: 6), + Flexible( child: Text( - isExternal ? "Außenlager: $label" : label, + 'Kein Scanvorgang notwendig', style: TextStyle( fontSize: 13, - fontWeight: FontWeight.w700, - color: fg, + color: color, + fontWeight: FontWeight.w500, ), ), ), @@ -1079,47 +681,670 @@ class _WarehouseSectionHeader extends StatelessWidget { } } -/// Plate-Badge für die AppBar — liest das aktiv gewählte Fahrzeug aus dem -/// [CarSelectBloc]. Nutzt einen halbtransparenten Hintergrund, damit das -/// Badge auch auf der Primary-Color-AppBar gut lesbar bleibt. -class _AppBarPlateBadge extends StatelessWidget { - const _AppBarPlateBadge(); +/// Sektions-Kopf vor den Items eines Lagers. Visuell klar getrennt +/// (Standardlager vs. Filiale): Standardlager ist der primäre +/// Arbeitsplatz und damit neutral koloriert; Filial-Sections +/// bekommen den Orange-Akzent und einen Hinweis-Text, dass sie nicht +/// am aktuellen Standort geladen werden. +/// +/// Counter rechts zeigt „fertige Items / Items in dieser Sektion". +class _WarehouseSectionHeader extends StatelessWidget { + const _WarehouseSectionHeader({ + required this.warehouse, + required this.items, + }); + + final Warehouse warehouse; + final List items; @override Widget build(BuildContext context) { - return BlocBuilder( - builder: (context, state) { - if (state is! CarSelectComplete) return const SizedBox.shrink(); - final onPrimary = Theme.of(context).colorScheme.onPrimary; - return Center( - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), - decoration: BoxDecoration( - color: onPrimary.withValues(alpha: 0.18), - borderRadius: BorderRadius.circular(8), - ), - child: Row( - mainAxisSize: MainAxisSize.min, + final theme = Theme.of(context); + final isStandard = warehouse.isStandard; + // Counter zählt nur **aktive** (nicht entfernte) Positionen — sonst + // wäre eine Lieferung mit allen Items entfernt nie „fertig" (0 / N), + // obwohl es nichts mehr zu beladen gibt. Entfernte Zeilen bleiben in + // der Liste sichtbar (durchgestrichen), zählen aber nicht. + final activeItems = items.where((it) => !it.isRemoved).toList(); + final doneCount = activeItems.where((it) => it.isDone).length; + final color = isStandard + ? theme.colorScheme.onSurfaceVariant + : Colors.amber.shade800; + + return Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + isStandard ? Icons.inventory_outlined : Icons.warehouse_outlined, + size: 18, + color: color, + ), + const SizedBox(width: 6), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon(Icons.local_shipping, size: 16, color: onPrimary), - const SizedBox(width: 6), Text( - state.selectedCar.plate, + isStandard + ? 'Standardlager' + : 'Filiale: ${warehouse.name}', style: TextStyle( - color: onPrimary, + fontSize: 13, fontWeight: FontWeight.bold, - fontSize: 14, + color: color, + letterSpacing: 0.4, + ), + ), + Text( + isStandard + ? 'Hier wird jetzt beladen' + : 'Wird in der Filiale separat geholt', + style: TextStyle( + fontSize: 11, + color: theme.colorScheme.onSurfaceVariant, ), ), ], ), ), - ); - }, + Padding( + padding: const EdgeInsets.only(top: 1), + child: Text( + '$doneCount / ${activeItems.length}', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: color, + ), + ), + ), + ], + ), ); } } -enum _MenuAction { cancel, hold } +/// Wird angezeigt, wenn eine Lieferung in der Beladen-Phase WEDER scanbare +/// Ware NOCH eine gebuchte Dienstleistung hat — also wirklich nichts zu tun +/// ist. Macht dem Fahrer klar, dass das ein normaler Zustand ist und kein +/// Datenfehler. (Gibt es eine Dienstleistung, erscheint stattdessen der +/// Abschnitt „Dienstleistungen" mit der echten Position.) +class _NothingToLoadHint extends StatelessWidget { + const _NothingToLoadHint(); -enum _CompletionChoice { overview, startTour } + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Padding( + padding: const EdgeInsets.fromLTRB(32, 32, 32, 16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.inbox_outlined, size: 56, color: theme.colorScheme.outline), + const SizedBox(height: 12), + Text( + 'Für diesen Kunden ist nichts zu beladen.', + style: theme.textTheme.titleMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 6), + Text( + 'Die Lieferung enthält keine zu ladende Ware.', + style: theme.textTheme.bodySmall, + textAlign: TextAlign.center, + ), + ], + ), + ); + } +} + +/// Runder Initialen-Avatar links neben dem Kundennamen. Macht den +/// sonst sehr text-lastigen Header griffiger und liefert dem Fahrer +/// einen visuellen Anker: gleicher Kunde = gleiche Farbe. +/// +/// Farbe ist deterministisch aus der `customer.id` abgeleitet, damit +/// Re-Builds und Page-Wechsel den Avatar nicht „flackern" lassen. +/// Initialen kommen aus dem Namen — Vor- und Zunamen kombiniert, +/// einzelne Worte mit einem Buchstaben. +class _CustomerAvatar extends StatelessWidget { + const _CustomerAvatar({required this.customer}); + + final Customer? customer; + + /// Kleine kuratierte Palette — kräftig genug zum Erkennen, aber nicht + /// schreiend. Reihenfolge ist Absicht: die ersten Farben fallen am + /// stärksten auf und sind damit für die häufigsten Kunden „zuerst + /// dran" (Hash-Modulo verteilt — über die Zeit ausgeglichen). + static const List _palette = [ + Color(0xFF1976D2), // blue 700 + Color(0xFF388E3C), // green 700 + Color(0xFFEF6C00), // orange 800 + Color(0xFF7B1FA2), // purple 700 + Color(0xFF00838F), // cyan 800 + Color(0xFF5D4037), // brown 700 + Color(0xFF455A64), // blueGrey 700 + ]; + + String get _initials { + final name = customer?.name.trim() ?? ''; + if (name.isEmpty) return '?'; + final parts = name + .split(RegExp(r'\s+')) + .where((p) => p.isNotEmpty) + .toList(growable: false); + if (parts.isEmpty) return '?'; + if (parts.length == 1) { + return parts.first.characters.first.toUpperCase(); + } + final first = parts.first.characters.first; + final last = parts.last.characters.first; + return '$first$last'.toUpperCase(); + } + + Color get _backgroundColor { + final seed = customer?.id.hashCode ?? 0; + return _palette[seed.abs() % _palette.length]; + } + + @override + Widget build(BuildContext context) { + if (customer == null) { + // Fallback: graues Person-Icon, weil kein Name → keine Initialen. + return const CircleAvatar( + radius: 26, + backgroundColor: Colors.grey, + child: Icon(Icons.person_outline, color: Colors.white), + ); + } + return CircleAvatar( + radius: 26, + backgroundColor: _backgroundColor, + child: Text( + _initials, + style: const TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + ); + } +} + +/// Farbiger Status-Badge unterhalb der Adresse — sichtbar bei `held`, +/// `canceled`, `completed`. Bei `active` blendet der Aufrufer den Badge +/// ganz aus, damit der Default-Fall die UI nicht zumüllt. +class _DeliveryStateBadge extends StatelessWidget { + const _DeliveryStateBadge({required this.delivery}); + + final Delivery delivery; + + @override + Widget build(BuildContext context) { + final (color, label, icon) = switch (delivery.state) { + DeliveryState.active => (Colors.blue, 'Aktiv', Icons.local_shipping), + DeliveryState.held => (Colors.orange, 'Pausiert', Icons.pause_circle), + DeliveryState.canceled => (Colors.red, 'Abgebrochen', Icons.cancel), + DeliveryState.completed => + (Colors.green, 'Abgeschlossen', Icons.check_circle), + }; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: color), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 14, color: color), + const SizedBox(width: 6), + Text( + label, + style: TextStyle(color: color, fontWeight: FontWeight.w600), + ), + if (delivery.stateReason != null && + delivery.stateReason!.isNotEmpty) ...[ + const SizedBox(width: 8), + Flexible( + child: Text( + '· ${delivery.stateReason}', + overflow: TextOverflow.ellipsis, + ), + ), + ], + ], + ), + ); + } +} + +/// AppBar-3-Punkte-Menü mit Delivery-Lifecycle-Aktionen. Optionen sind +/// kontextabhängig vom aktuellen `state` — endgültige Zustände +/// (canceled, completed) bieten keine Aktion mehr; das Icon verschwindet +/// dann ganz, damit der Fahrer keine Sackgasse antippt. +class _DeliveryActionsMenu extends StatelessWidget { + const _DeliveryActionsMenu({ + required this.delivery, + required this.onHold, + required this.onCancel, + required this.onResume, + }); + + final Delivery delivery; + final VoidCallback onHold; + final VoidCallback onCancel; + final VoidCallback onResume; + + @override + Widget build(BuildContext context) { + final items = >[]; + switch (delivery.state) { + case DeliveryState.active: + items.add(const PopupMenuItem( + value: _DeliveryAction.hold, + child: ListTile( + leading: Icon(Icons.pause_circle_outline), + title: Text('Lieferung pausieren'), + contentPadding: EdgeInsets.zero, + ), + )); + items.add(const PopupMenuItem( + value: _DeliveryAction.cancel, + child: ListTile( + leading: Icon(Icons.cancel_outlined, color: Colors.red), + title: Text('Lieferung abbrechen'), + contentPadding: EdgeInsets.zero, + ), + )); + case DeliveryState.held: + items.add(const PopupMenuItem( + value: _DeliveryAction.resume, + child: ListTile( + leading: Icon(Icons.play_circle_outline), + title: Text('Lieferung fortsetzen'), + contentPadding: EdgeInsets.zero, + ), + )); + items.add(const PopupMenuItem( + value: _DeliveryAction.cancel, + child: ListTile( + leading: Icon(Icons.cancel_outlined, color: Colors.red), + title: Text('Lieferung abbrechen'), + contentPadding: EdgeInsets.zero, + ), + )); + case DeliveryState.canceled: + items.add(const PopupMenuItem( + value: _DeliveryAction.resume, + child: ListTile( + leading: Icon(Icons.restore, color: Colors.green), + title: Text('Lieferung wiederherstellen'), + contentPadding: EdgeInsets.zero, + ), + )); + case DeliveryState.completed: + return const SizedBox.shrink(); + } + + return PopupMenuButton<_DeliveryAction>( + tooltip: 'Lieferungs-Aktionen', + onSelected: (action) { + switch (action) { + case _DeliveryAction.hold: + onHold(); + case _DeliveryAction.cancel: + onCancel(); + case _DeliveryAction.resume: + onResume(); + } + }, + itemBuilder: (_) => items, + ); + } +} + +enum _DeliveryAction { hold, cancel, resume } + +enum _ItemAction { remove, unremove, manualConfirm } + +/// Karten-Darstellung einer Item-Position in der Beladen-Phase. +/// +/// Visueller Stil orientiert sich an `_OverviewTile` (Lieferungs-Karten +/// in der Beladen-Übersicht): semi-transparente Hintergrundfarbe + Border +/// codieren den Status, links ein Status-Icon, rechts die Mengen-Anzeige. +/// Bei `isDone` ein zusätzliches grünes Häkchen, damit „fertig" ohne +/// Mengenrechnung auf einen Blick erkennbar ist. +/// Sortiert Items so, dass innerhalb einer Belegzeile der Oberartikel VOR +/// seinen Komponenten steht (für die eingerückte Darstellung). +List _parentFirst(List items) { + final sorted = List.of(items); + sorted.sort((a, b) { + final byLine = a.belegzeilenNr.compareTo(b.belegzeilenNr); + if (byLine != 0) return byLine; + final byParent = (a.isComponent ? 1 : 0).compareTo(b.isComponent ? 1 : 0); + if (byParent != 0) return byParent; + return (a.komponentenArtikelNr ?? '').compareTo(b.komponentenArtikelNr ?? ''); + }); + return sorted; +} + +class _ItemRow extends StatelessWidget { + const _ItemRow({ + required this.item, + required this.details, + required this.onAction, + this.scanNotRequired = false, + }); + + final DeliveryItem item; + final TourDetails details; + final void Function(_ItemAction action) onAction; + + /// `true` für gebuchte Dienstleistungen (nicht-scanbare Positionen): die + /// Card wird GENAU SO wie ein Standardlager-Artikel gerendert, bekommt aber + /// unten den Hinweis „Kein Scanvorgang notwendig". Der Manuell-Button + /// entfällt bei nicht-scanbaren Positionen ohnehin (`canManualConfirm`). + final bool scanNotRequired; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final article = details.articleOf(item.articleId); + final warehouse = details.warehouseOf(item.warehouseId); + final isExternalWarehouse = warehouse != null && !warehouse.isStandard; + // Manueller Fallback-Button: nur für scanbare, noch offene Positionen + // (nicht done/entfernt/pausiert) — analog dazu, was ein Barcode-Scan + // dieser Zeile tun würde. + final canManualConfirm = article != null && + article.scannable && + !item.isDone && + !item.isRemoved && + !item.isHeld; + + // Status-abhängiger Style — gleiches Farbschema wie `_OverviewTile`, + // damit Übersicht und Detail visuell zusammenpassen. + final Color cardColor; + final Color borderColor; + final Color titleColor; + final Color quantityColor; + final IconData leadingIcon; + final Color leadingIconColor; + final String? statusBadgeLabel; + + if (item.isRemoved) { + cardColor = Colors.grey.withValues(alpha: 0.08); + borderColor = Colors.grey.withValues(alpha: 0.35); + titleColor = Colors.grey.shade700; + quantityColor = Colors.grey; + leadingIcon = Icons.delete_outline; + leadingIconColor = Colors.grey.shade700; + statusBadgeLabel = 'Entfernt'; + } else if (item.isHeld) { + cardColor = Colors.orange.withValues(alpha: 0.07); + borderColor = Colors.orange.withValues(alpha: 0.35); + titleColor = Colors.orange.shade800; + quantityColor = Colors.orange.shade800; + leadingIcon = Icons.pause_circle_outline; + leadingIconColor = Colors.orange.shade800; + statusBadgeLabel = 'Pausiert'; + } else if (item.isDone) { + cardColor = Colors.green.withValues(alpha: 0.07); + borderColor = Colors.green.withValues(alpha: 0.35); + titleColor = Colors.green.shade700; + quantityColor = Colors.green.shade700; + leadingIcon = Icons.inventory_2_outlined; + leadingIconColor = Colors.green.shade700; + statusBadgeLabel = null; + } else if (item.scanProgress.scannedQuantity > 0) { + cardColor = Colors.orange.withValues(alpha: 0.07); + borderColor = Colors.orange.withValues(alpha: 0.35); + titleColor = Colors.orange.shade800; + quantityColor = Colors.orange.shade800; + leadingIcon = Icons.inventory_2_outlined; + leadingIconColor = Colors.orange.shade800; + statusBadgeLabel = null; + } else { + cardColor = theme.colorScheme.surfaceContainerLow; + borderColor = Colors.transparent; + titleColor = theme.colorScheme.onSurface; + quantityColor = theme.colorScheme.onSurface; + leadingIcon = Icons.inventory_2_outlined; + leadingIconColor = theme.colorScheme.onSurfaceVariant; + statusBadgeLabel = null; + } + + return Opacity( + opacity: item.isRemoved ? 0.55 : 1.0, + child: Card( + // Komponenten weiter links eingerückt → gehören zum Oberartikel darüber. + margin: EdgeInsets.only( + left: item.isComponent ? 32 : 12, + right: 12, + top: 4, + bottom: 4, + ), + elevation: 0, + color: cardColor, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide(color: borderColor), + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 12, 4, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + // Default `crossAxisAlignment: center` — Icon links, Mengen- + // Anzeige rechts und der Aktions-Menü-Button sitzen damit + // vertikal mittig zum Inhalts-Block in der Mitte. Wirkt + // ausgeglichener, wenn der Subtitle mehrere Zeilen hat + // (Art-Nr, Lager, Komponente, Reason). + children: [ + Icon(leadingIcon, color: leadingIconColor, size: 28), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Text( + '${item.isComponent ? '↳ ' : ''}${_articleTitle(article)}', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: titleColor, + // Entfernte Positionen werden im Detail-Screen + // **nicht** rausgefiltert, sondern bleiben + // sichtbar — durchgestrichen, damit der Fahrer + // erkennt „hatten wir, ist raus" und über das + // Aktions-Menü ggf. wiederherstellen kann. + decoration: item.isRemoved + ? TextDecoration.lineThrough + : null, + ), + ), + ), + if (statusBadgeLabel != null) + Container( + margin: const EdgeInsets.only(left: 8), + padding: const EdgeInsets.symmetric( + horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: titleColor.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(4), + border: Border.all(color: titleColor), + ), + child: Text( + statusBadgeLabel, + style: TextStyle( + color: titleColor, + fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + const SizedBox(height: 2), + Text( + 'Art.-Nr.: ${article?.articleNumber ?? '⟨unbekannt⟩'}', + style: TextStyle( + fontSize: 12, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + // Lager-Angabe nur für scanbare Artikel. Dienstleistungen + // (`scanNotRequired`) werden nicht aus einem Lager beladen + // → die Lager-Zeile wäre irrelevant und entfällt. + if (warehouse != null && !scanNotRequired) + Text( + 'Lager: ${warehouse.name}' + '${isExternalWarehouse ? ' (Filiale)' : ''}', + style: TextStyle( + fontSize: 12, + color: isExternalWarehouse + ? Colors.amber.shade800 + : theme.colorScheme.onSurfaceVariant, + fontWeight: isExternalWarehouse + ? FontWeight.w600 + : FontWeight.normal, + ), + ), + if (item.isHeld && + item.scanProgress.heldReason != null && + item.scanProgress.heldReason!.isNotEmpty) + Text( + 'Grund: ${item.scanProgress.heldReason}', + style: TextStyle( + fontSize: 12, + color: Colors.orange.shade800, + ), + ), + ], + ), + ), + // Mengen-Zähler nur für scanbare Artikel. Dienstleistungen + // (`scanNotRequired`) werden nicht gescannt → eine „X / Y"- + // Anzeige wäre dort sinnlos und wird weggelassen. + if (!scanNotRequired) ...[ + const SizedBox(width: 8), + Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '${item.scanProgress.scannedQuantity} / ${item.requiredQuantity}', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + color: quantityColor, + ), + ), + // Grünes Häkchen sobald die Soll-Menge erreicht ist — + // schneller visueller Anker als die Zahlen-Differenz. + if (item.isDone) ...[ + const SizedBox(width: 4), + Icon( + Icons.check_circle, + size: 20, + color: Colors.green.shade700, + ), + ], + ], + ), + ], + ), + ], + _ItemActionMenu(item: item, onSelected: onAction), + ], + ), + if (canManualConfirm) ...[ + const SizedBox(height: 8), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: () => onAction(_ItemAction.manualConfirm), + icon: const Icon(Icons.check_circle_outline, size: 18), + label: const Text('Manuell als geladen bestätigen'), + ), + ), + ], + // Dienstleistung (nicht-scanbar): Hinweis statt Scan/Manuell- + // Aktion. Steht an derselben Stelle wie der Manuell-Button. + if (scanNotRequired) ...[ + const SizedBox(height: 8), + const _ScanNotRequiredHint(), + ], + ], + ), + ), + ), + ); + } + + String _articleTitle(Article? article) { + if (article == null) return item.articleId; + return article.name; + } +} + +/// 3-Punkte-Menü am Ende einer Item-Row. Welche Aktion sichtbar ist, +/// hängt vom aktuellen ScanStatus ab: +/// * `removed` → „Wiederherstellen" +/// * sonst → „Entfernen" +/// +/// Item-Pausieren wird vom UI bewusst **nicht** angeboten — das Backend +/// kennt die Hold-Action zwar weiterhin, in der Fahrer-App ist sie aber +/// nicht mehr Teil des Workflows. +class _ItemActionMenu extends StatelessWidget { + const _ItemActionMenu({required this.item, required this.onSelected}); + + final DeliveryItem item; + final void Function(_ItemAction) onSelected; + + @override + Widget build(BuildContext context) { + return PopupMenuButton<_ItemAction>( + tooltip: 'Artikel-Aktionen', + icon: const Icon(Icons.more_vert), + onSelected: onSelected, + itemBuilder: (_) { + if (item.isRemoved) { + return const [ + PopupMenuItem( + value: _ItemAction.unremove, + child: ListTile( + leading: Icon(Icons.restore, color: Colors.green), + title: Text('Wiederherstellen'), + contentPadding: EdgeInsets.zero, + ), + ), + ]; + } + return const [ + PopupMenuItem( + value: _ItemAction.remove, + child: ListTile( + leading: Icon(Icons.delete_outline, color: Colors.red), + title: Text('Entfernen'), + contentPadding: EdgeInsets.zero, + ), + ), + ]; + }, + ); + } +} diff --git a/lib/feature/loading/presentation/loading_overview_page.dart b/lib/feature/loading/presentation/loading_overview_page.dart index 0fd6a46..b411bda 100644 --- a/lib/feature/loading/presentation/loading_overview_page.dart +++ b/lib/feature/loading/presentation/loading_overview_page.dart @@ -1,75 +1,100 @@ -import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:hl_lieferservice/domain/entity/delivery.dart'; +import 'package:hl_lieferservice/domain/entity/delivery_item.dart'; +import 'package:hl_lieferservice/domain/entity/tour_details.dart'; import 'package:hl_lieferservice/feature/car_selection/bloc/bloc.dart'; import 'package:hl_lieferservice/feature/car_selection/bloc/state.dart'; +import 'package:hl_lieferservice/feature/cars/bloc/cars_bloc.dart'; +import 'package:hl_lieferservice/feature/cars/bloc/cars_state.dart'; +import 'package:hl_lieferservice/feature/delivery/bloc/phase_bloc.dart'; +import 'package:hl_lieferservice/feature/delivery/bloc/phase_event.dart'; import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.dart'; import 'package:hl_lieferservice/feature/delivery/bloc/tour_state.dart'; import 'package:hl_lieferservice/feature/delivery/model/delivery_phase.dart'; import 'package:hl_lieferservice/feature/delivery/overview/presentation/delivery_fail_page.dart'; -import 'package:hl_lieferservice/feature/loading/model/loading_group.dart'; import 'package:hl_lieferservice/feature/loading/presentation/loading_customer_page.dart'; -import 'package:hl_lieferservice/feature/loading/util/loading_order.dart'; -import 'package:hl_lieferservice/model/delivery.dart'; -import 'package:hl_lieferservice/model/tour.dart'; import 'package:hl_lieferservice/widget/home/presentation/home_drawer.dart'; import 'package:hl_lieferservice/widget/phase_stepper/phase_stepper.dart'; -/// Übersichts-Ansicht für die Beladen-Phase: alle Kunden in Beladereihen- -/// folge mit Fortschritts-Status. KEIN Scanner — der Scanner-Fokus bleibt -/// auf der [LoadingCustomerPage]. Tap auf einen Kunden öffnet seine -/// Vollbild-Ansicht mit dem entsprechenden Index. +/// Übersichts-Ansicht für die Beladen-Phase: alle Kunden mit ihren +/// Artikeln und Soll/Ist-Mengen. +/// +/// Scans werden in der `LoadingCustomerPage` (Vollbild pro Kunde) +/// ausgelöst; diese Übersicht ist reines Status-Display und Navigation. +/// Der Button „Auslieferungs-Phase starten" unten erlaubt dem Fahrer, +/// jederzeit in die nächste Phase zu wechseln — die App erzwingt +/// bewusst keine 100%-Auslieferung (z. B. wenn ein Artikel fehlt und +/// der Fahrer das später hold/cancel-en will). class LoadingOverviewPage extends StatelessWidget { const LoadingOverviewPage({super.key}); - String? _lookupCarPlate(String? carId, Tour tour) { + String? _plateFor(BuildContext context, String? carId) { if (carId == null) return null; - return tour.driver.cars.firstWhereOrNull((c) => c.id == carId)?.plate; + final state = context.read().state; + if (state is! CarsLoaded) return null; + for (final c in state.cars) { + if (c.id == carId) return c.plate; + } + return null; } - List _buildGroups(TourLoaded state, String carIdStr) { - final orderedIds = LoadingOrder.computeForCar( - state: state, - carIdStr: carIdStr, - ); - final byId = {for (final d in state.tour.deliveries) d.id: d}; - final groups = []; - for (final id in orderedIds) { - final delivery = byId[id]; - if (delivery == null) continue; - if (delivery.state == DeliveryState.finished) continue; - final scannable = - delivery.articles.where((a) => a.scannable).toList(growable: false); - if (scannable.isEmpty && delivery.state != DeliveryState.canceled) { - continue; - } - groups.add(LoadingGroup( - delivery: delivery, - articles: scannable, - carPlate: _lookupCarPlate(delivery.carId, state.tour), - )); - } - return groups; + /// Beladereihenfolge = umgekehrte Sortier-Reihenfolge: wer zuletzt + /// ausgeliefert wird, wird zuerst beladen (kommt unten in den LKW). + List _ownDeliveriesInLoadingOrder( + TourDetails details, + String carId, + bool multiCarTeam, + ) { + final relevant = multiCarTeam + ? details.deliveriesSorted + .where((d) => d.assignedCarId == carId) + .toList() + : details.deliveriesSorted; + return relevant.reversed.toList(); } @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, carState) { - final carIdStr = - carState is CarSelectComplete ? carState.selectedCar.id.toString() : ""; + final carId = + carState is CarSelectComplete ? carState.selectedCar.id : ''; return BlocBuilder( builder: (context, tourState) { - if (tourState is TourLoadingFailed) { + if (tourState is TourLoadFailed) { return const DeliveryLoadingFailedPage(); } + if (tourState is TourEmpty) { + return Scaffold( + drawer: const HomeAppDrawer(), + appBar: AppBar(title: const Text('Beladung')), + body: const _EmptyOverview(), + ); + } if (tourState is! TourLoaded) { return const Scaffold( body: Center(child: CircularProgressIndicator()), ); } - final groups = _buildGroups(tourState, carIdStr); + + final carsState = context.read().state; + final multiCarTeam = + carsState is CarsLoaded && carsState.cars.length >= 2; + final ordered = _ownDeliveriesInLoadingOrder( + tourState.details, + carId, + multiCarTeam, + ); + + // Gate für „Auslieferungs-Phase starten": jede aktive Lieferung + // muss im Standardlager fertig beladen sein. Pausierte/abgebrochene + // zählen nicht; offene Filial-Items dürfen bleiben (werden unterwegs + // geholt — siehe Status-Logik). Keine aktive Lieferung ⇒ kein Block. + final canStart = ordered + .where((d) => d.state == DeliveryState.active) + .every(tourState.details.standardWarehouseLoadingDone); return Scaffold( drawer: const HomeAppDrawer(), @@ -77,15 +102,20 @@ class LoadingOverviewPage extends StatelessWidget { preferredSize: const Size.fromHeight(140), child: PhaseStepper( currentPhase: DeliveryPhase.beladen, - carId: carIdStr, + carId: carId, ), ), body: SafeArea( top: false, - child: groups.isEmpty + child: ordered.isEmpty ? const _EmptyOverview() - : _OverviewList(groups: groups), + : _OverviewList( + deliveries: ordered, + details: tourState.details, + plateResolver: (id) => _plateFor(context, id), + ), ), + bottomNavigationBar: _BottomBar(carId: carId, canStart: canStart), ); }, ); @@ -95,18 +125,104 @@ class LoadingOverviewPage extends StatelessWidget { } class _OverviewList extends StatelessWidget { - const _OverviewList({required this.groups}); + const _OverviewList({ + required this.deliveries, + required this.details, + required this.plateResolver, + }); - final List groups; + final List deliveries; + final TourDetails details; + final String? Function(String?) plateResolver; + + /// Klassifiziert eine Lieferung in einen UX-Bucket. Bucket bestimmt, + /// in welcher Sektion das Tile in der Übersicht landet. + /// + /// Lifecycle-Zustände (`held` / `canceled`) gewinnen vor der + /// Standardlager-/Filial-Logik: eine pausierte oder abgebrochene + /// Lieferung soll nicht versehentlich als „nächste Lieferung" oder + /// „offen" auftauchen, auch wenn ihr Standardlager-Counter Items + /// hätte. + _OverviewBucket _bucketOf(Delivery delivery) { + if (delivery.state == DeliveryState.canceled) { + return _OverviewBucket.canceled; + } + if (delivery.state == DeliveryState.held) { + return _OverviewBucket.paused; + } + final standardDone = details.standardWarehouseLoadingDone(delivery); + final hasExternal = details.hasExternalWarehouseItems(delivery); + if (standardDone && hasExternal) return _OverviewBucket.later; + if (standardDone) return _OverviewBucket.done; + return _OverviewBucket.open; + } @override Widget build(BuildContext context) { - final totalActive = groups - .where((g) => g.delivery.state != DeliveryState.canceled) + // Standardlager-Items pro Lieferung — Basis für den Fortschritts- + // Counter („X / Y Artikel" am Kunden). Filial-Items zählen hier + // nicht; sie werden separat geladen und sollen den Fortschritt im + // Hauptlager nicht verwässern. + final standardItemsPerDelivery = >{}; + for (final d in deliveries) { + standardItemsPerDelivery[d.id] = d.items.where((it) { + if (it.isRemoved) return false; + if (!details.isArticleScannable(it.articleId)) return false; + final w = details.warehouseOf(it.warehouseId); + return w?.isStandard ?? false; + }).toList(); + } + + // Lieferungen in drei Buckets sortieren. Innerhalb der Buckets + // bleibt die Belade-Reihenfolge erhalten (Original-Index), damit der + // Fahrer „Kunde Nr. 3 in der Reihenfolge" jederzeit identifizieren + // kann — auch wenn die Karte gerade in „Später abholen" einsortiert + // ist. + final open = <_OverviewEntry>[]; + final later = <_OverviewEntry>[]; + final paused = <_OverviewEntry>[]; + final done = <_OverviewEntry>[]; + final canceled = <_OverviewEntry>[]; + for (int i = 0; i < deliveries.length; i++) { + final d = deliveries[i]; + final entry = _OverviewEntry( + originalIndex: i, + delivery: d, + standardItems: standardItemsPerDelivery[d.id] ?? const [], + ); + switch (_bucketOf(d)) { + case _OverviewBucket.open: + open.add(entry); + case _OverviewBucket.later: + later.add(entry); + case _OverviewBucket.paused: + paused.add(entry); + case _OverviewBucket.done: + done.add(entry); + case _OverviewBucket.canceled: + canceled.add(entry); + } + } + + // „Nächste Lieferung" zieht den ersten Offen-Eintrag heraus und + // präsentiert ihn in einer eigenen Sektion ganz oben — der Fahrer + // sieht damit immer sofort, was als nächstes anliegt, statt aus + // einer Liste die Position-Nr. 1 selbst rauszusuchen. Sobald die + // Lieferung beladen ist, rutscht sie in „Später abholen" oder + // „Fertig" und der Eintrag auf Position 2 wird zur neuen + // „Nächsten" — automatisch, da diese Logik bei jedem Build neu + // läuft. + final _OverviewEntry? nextUp = open.isEmpty ? null : open.removeAt(0); + + final totalActive = deliveries + .where((d) => d.state != DeliveryState.canceled) .length; - final doneActive = groups - .where((g) => - g.delivery.state != DeliveryState.canceled && g.isComplete) + // „Fertig beladen" = Standardlager fertig. Filial-Items + // blockieren den Übergang in die Auslieferungs-Phase nicht. + final doneActive = deliveries + .where((d) => + d.state != DeliveryState.canceled && + details.standardWarehouseLoadingDone(d)) .length; return Column( @@ -120,18 +236,42 @@ class _OverviewList extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( - "Beladereihenfolge", + 'Beladereihenfolge', style: Theme.of(context).textTheme.titleMedium, ), Text( - "$doneActive / $totalActive Kunden", + '$doneActive / $totalActive Kunden', style: Theme.of(context).textTheme.titleSmall?.copyWith( fontWeight: FontWeight.bold, ), ), ], ), - const SizedBox(height: 6), + const SizedBox(height: 4), + // Macht den Bezugsrahmen der Phase explizit: Es wird im + // Standardlager beladen. Filial-Artikel sind die Ausnahme + // und werden separat geholt — sonst entsteht der Eindruck, + // man müsse für jede Lieferung mehrere Lager ansteuern. + Row( + children: [ + Icon( + Icons.inventory_2_outlined, + size: 14, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 4), + Expanded( + child: Text( + 'Beladung im Standardlager · Filial-Artikel werden separat geholt', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: + Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + const SizedBox(height: 8), ClipRRect( borderRadius: BorderRadius.circular(4), child: LinearProgressIndicator( @@ -140,11 +280,6 @@ class _OverviewList extends StatelessWidget { backgroundColor: Theme.of(context) .colorScheme .surfaceContainerHighest, - valueColor: AlwaysStoppedAnimation( - doneActive == totalActive && totalActive > 0 - ? Colors.green - : Theme.of(context).primaryColor, - ), ), ), ], @@ -152,27 +287,64 @@ class _OverviewList extends StatelessWidget { ), const Divider(height: 1), Expanded( - child: ListView.builder( - padding: const EdgeInsets.only(top: 8, bottom: 16), - itemCount: groups.length, - itemBuilder: (context, index) { - final g = groups[index]; - return _OverviewTile( - position: index + 1, - group: g, - onTap: () { - // Push (kein pushReplacement): die Übersicht ist seit dem - // Routing-Umbau in home.dart die Wurzel der Beladen-Phase. - // Vom Vollbild kehrt der Fahrer per pop zurück auf diese - // Übersicht — der Stack bleibt damit flach. - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => LoadingCustomerPage(initialIndex: index), - ), - ); - }, - ); - }, + child: ListView( + padding: const EdgeInsets.only(top: 4, bottom: 16), + children: [ + if (nextUp != null) + _BucketSection( + title: 'Nächste Lieferung', + count: 1, + color: Theme.of(context).colorScheme.primary, + icon: Icons.local_shipping_outlined, + entries: [nextUp], + details: details, + ), + if (open.isNotEmpty) + _BucketSection( + title: 'Offen', + count: open.length, + color: Theme.of(context) + .colorScheme + .primary + .withValues(alpha: 0.55), + entries: open, + details: details, + ), + if (later.isNotEmpty) + _BucketSection( + title: 'Später abholen', + count: later.length, + color: Colors.amber.shade800, + entries: later, + details: details, + ), + if (paused.isNotEmpty) + _BucketSection( + title: 'Pausiert', + count: paused.length, + color: Colors.orange.shade800, + icon: Icons.pause_circle_outline, + entries: paused, + details: details, + ), + if (done.isNotEmpty) + _BucketSection( + title: 'Fertig', + count: done.length, + color: Colors.green.shade700, + entries: done, + details: details, + ), + if (canceled.isNotEmpty) + _BucketSection( + title: 'Abgebrochen', + count: canceled.length, + color: Colors.red.shade700, + icon: Icons.cancel_outlined, + entries: canceled, + details: details, + ), + ], ), ), ], @@ -183,81 +355,138 @@ class _OverviewList extends StatelessWidget { class _OverviewTile extends StatelessWidget { const _OverviewTile({ required this.position, - required this.group, + required this.delivery, + required this.standardItems, + required this.details, required this.onTap, }); final int position; - final LoadingGroup group; + final Delivery delivery; + + /// Scanbare, nicht-entfernte Items aus dem Standardlager. Basis für + /// den Beladen-Fortschritt — Filial-Items werden separat geladen + /// und zählen hier nicht mit. + final List standardItems; + final TourDetails details; final VoidCallback onTap; @override Widget build(BuildContext context) { final theme = Theme.of(context); - final canceled = group.delivery.state == DeliveryState.canceled; - final isComplete = group.isComplete; - final isPartial = group.isPartial; - final hasExternalWarehouse = group.hasExternalWarehouseArticles; + final canceled = delivery.state == DeliveryState.canceled; + final held = delivery.state == DeliveryState.held; + final standardDone = details.standardWarehouseLoadingDone(delivery); + final hasExternal = details.hasExternalWarehouseItems(delivery); + final externalLabels = details.externalWarehouseLabels(delivery); + final scannedStandardCount = + standardItems.where((it) => it.isDone).length; + final scannedAnyStandard = + standardItems.any((it) => it.scanProgress.scannedQuantity > 0); + final customer = details.customerOf(delivery); - // cardColor und borderColor sind nicht final, weil das Außenlager- - // Highlight sie weiter unten ggf. überschreibt. Color cardColor; Color borderColor; - final Color titleColor; - final String statusText; - final IconData statusIcon; + Color titleColor; + String statusText; + IconData statusIcon; + // Lifecycle-States gewinnen vor der Beladen-Logik: pausiert / + // abgebrochen sollen visuell sofort als solche erkennbar sein — + // egal wie weit der Standardlager-Counter war. if (canceled) { - cardColor = Colors.grey.withValues(alpha: 0.08); - borderColor = Colors.grey.withValues(alpha: 0.35); - titleColor = Colors.grey.shade700; - statusText = "Abgebrochen"; + cardColor = Colors.red.withValues(alpha: 0.06); + borderColor = Colors.red.withValues(alpha: 0.35); + titleColor = Colors.red.shade700; + statusText = 'Abgebrochen'; statusIcon = Icons.cancel_outlined; - } else if (isComplete && hasExternalWarehouse) { - // Standardlager ist fertig, aber es liegen noch Artikel in einem - // anderen Lager — die Lieferung ist also NICHT komplett beladen. - // Wir machen das im Status-Text explizit, damit der Fahrer nicht - // fälschlich davon ausgeht, dass nichts mehr offen ist. - cardColor = Colors.deepOrange.withValues(alpha: 0.10); - borderColor = Colors.deepOrange.withValues(alpha: 0.45); - titleColor = Colors.deepOrange.shade800; - statusText = "Standardlager fertig — Außenlager offen"; + } else if (held) { + cardColor = Colors.orange.withValues(alpha: 0.07); + borderColor = Colors.orange.withValues(alpha: 0.45); + titleColor = Colors.orange.shade800; + statusText = 'Pausiert'; + statusIcon = Icons.pause_circle_outline; + } else if (standardDone && hasExternal) { + // Sonderfall, fachlich wichtig: Standardlager ist durch, aber im + // Filiale wartet noch was. Der Fahrer kann technisch in die + // Auslieferungs-Phase, muss aber wissen, dass er zwischendurch + // ein zweites Lager ansteuert. + cardColor = Colors.amber.withValues(alpha: 0.18); + borderColor = Colors.amber.withValues(alpha: 0.55); + titleColor = Colors.amber.shade800; + statusText = 'Standardlager fertig — Filiale offen'; statusIcon = Icons.warehouse_outlined; - } else if (isComplete) { + } else if (standardDone) { cardColor = Colors.green.withValues(alpha: 0.07); borderColor = Colors.green.withValues(alpha: 0.35); titleColor = Colors.green.shade700; - statusText = "Fertig beladen"; + statusText = 'Fertig beladen'; statusIcon = Icons.check_circle_outline; - } else if (isPartial) { + } else if (scannedAnyStandard) { cardColor = Colors.orange.withValues(alpha: 0.07); borderColor = Colors.orange.withValues(alpha: 0.35); titleColor = Colors.orange.shade800; - statusText = "Beladung läuft"; + statusText = 'Beladung läuft'; statusIcon = Icons.pending_outlined; } else { cardColor = theme.colorScheme.surfaceContainerLow; borderColor = Colors.transparent; titleColor = theme.colorScheme.onSurface; - statusText = "Offen"; + statusText = 'Offen'; statusIcon = Icons.radio_button_unchecked; } - // Außenlager-Hervorhebung: lebt unabhängig vom Scan-Status. Eine - // abgebrochene Lieferung bleibt grau, ansonsten überschreibt das - // Außenlager-Highlight die Standard-Farben durch ein klar erkennbares - // Orange — der Fahrer muss früh genug wissen, dass er ein anderes - // Lager anfahren wird. Der Sonderzweig "isComplete && hasExternal- - // Warehouse" oben hat das Highlight schon gesetzt, hier greift es - // für die noch nicht fertigen Fälle. - if (!canceled && hasExternalWarehouse && !isComplete) { - cardColor = Colors.deepOrange.withValues(alpha: 0.10); - borderColor = Colors.deepOrange.withValues(alpha: 0.65); + // Bewusst KEIN Filial-Card-Highlight, solange das Standardlager + // noch offen ist: eine Lieferung mit Standard- UND Filial-Items + // soll sich nicht von einer reinen Standardlager-Lieferung + // unterscheiden — der Fahrer belädt zuerst das Standardlager, und eine + // orange Karte würde fälschlich einen Sonderzustand suggerieren + // (irreführend besonders in der „Nächste Lieferung"-Sektion). Den + // Hinweis aufs Filiale trägt allein das `_ExternalWarehouseBadge` + // weiter unten. Der echte Sonderfall „Standardlager fertig — Filiale + // offen" wird oben in der Status-Kette eigenständig amber gefärbt. + + // Fortschritts-Label rechts in der Status-Zeile. + // + // „X / Y Artikel" bezieht sich bewusst nur auf das Standardlager (siehe + // `standardItems`). Hat eine Lieferung dort GAR KEINE Position, ergäbe das + // ein irreführendes „0 / 0 Artikel" — die Card sähe leer aus, obwohl die + // Ware nur aus einer Filiale kommt (später abholen) oder es sich um eine + // reine Dienstleistung handelt. In diesen Fällen zeigen wir statt des + // Zählers einen sprechenden Hinweis. So wirkt keine Card mehr „verloren". + final String progressLabel; + if (canceled || held) { + progressLabel = '—'; + } else if (standardItems.isNotEmpty) { + progressLabel = '$scannedStandardCount / ${standardItems.length} Artikel'; + } else if (hasExternal) { + // Keine Standardlager-Ware, aber Filial-Items → wird separat geholt. + // Das Filial-Badge unten trägt die Details; hier nur der Kurz-Hinweis. + progressLabel = 'Nur Filiale'; + } else if (details.hasServiceItems(delivery)) { + // Weder Standardlager- noch Filial-Ware, aber eine nicht-scanbare + // Position → reine Dienstleistung. Rechtfertigt trotzdem die Anfahrt. + progressLabel = 'Nur Dienstleistung'; + } else { + // Defensive: keinerlei relevante Positionen — sollte praktisch nicht + // vorkommen. Kein „0 / 0", sondern neutraler Strich. + progressLabel = '—'; } - final progressLabel = canceled - ? "—" - : "${group.completeArticles}/${group.totalArticles} Artikel"; + // Avatar-Hintergrund spiegelt den Lifecycle-State wider, damit die + // Nummer-Bubble nicht weiterhin primary leuchtet, obwohl die + // Lieferung pausiert/abgebrochen ist. + final Color avatarColor; + if (canceled) { + avatarColor = Colors.red.shade700; + } else if (held) { + avatarColor = Colors.orange.shade800; + } else { + avatarColor = theme.colorScheme.primary; + } + + final reason = delivery.stateReason; + final showReason = (canceled || held) && reason != null && reason.isNotEmpty; return Opacity( opacity: canceled ? 0.65 : 1.0, @@ -277,12 +506,11 @@ class _OverviewTile extends StatelessWidget { child: Row( children: [ CircleAvatar( - backgroundColor: - canceled ? Colors.grey : theme.colorScheme.primary, + backgroundColor: avatarColor, foregroundColor: theme.colorScheme.onPrimary, radius: 18, child: Text( - "$position", + '$position', style: const TextStyle(fontWeight: FontWeight.bold), ), ), @@ -292,7 +520,7 @@ class _OverviewTile extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - group.delivery.customer.name, + customer?.name ?? '⟨Unbekannter Kunde⟩', style: TextStyle( fontSize: 15, fontWeight: FontWeight.w600, @@ -304,7 +532,7 @@ class _OverviewTile extends StatelessWidget { ), const SizedBox(height: 2), Text( - group.delivery.customer.address.toString(), + delivery.deliveryAddressSnapshot.oneLine, style: TextStyle( fontSize: 12, color: theme.colorScheme.onSurfaceVariant, @@ -320,9 +548,6 @@ class _OverviewTile extends StatelessWidget { size: 14, color: titleColor), ), const SizedBox(width: 4), - // Expanded, damit lange Status-Texte wie - // "Standardlager fertig — Außenlager offen" - // umbrechen statt zu überlaufen. Expanded( child: Text( statusText, @@ -344,11 +569,25 @@ class _OverviewTile extends StatelessWidget { ), ], ), - if (!canceled && hasExternalWarehouse) ...[ - const SizedBox(height: 6), - _ExternalWarehouseBadge( - labels: group.externalWarehouseLabels, + if (showReason) + Padding( + padding: const EdgeInsets.only(left: 18, top: 2), + child: Text( + 'Grund: $reason', + style: TextStyle( + fontSize: 11, + color: titleColor, + ), + ), ), + // Filial-Badge nur sichtbar, wenn die Lieferung + // im aktiven Workflow ist. Pausiert / abgebrochen + // soll keine zusätzliche Filial-Aufmerksamkeit + // ziehen — die Aktion ist gerade unterbrochen oder + // beendet. + if (!canceled && !held && hasExternal) ...[ + const SizedBox(height: 6), + _ExternalWarehouseBadge(labels: externalLabels), ], ], ), @@ -363,9 +602,201 @@ class _OverviewTile extends StatelessWidget { } } -/// Hinweis-Badge unter dem Status-Row einer Lieferung mit Artikeln aus -/// einem oder mehreren Außenlagern. Listet die betroffenen Lager-Namen -/// auf, damit der Fahrer beim Beladen weiß, wohin er zusätzlich muss. +class _BottomBar extends StatelessWidget { + const _BottomBar({required this.carId, required this.canStart}); + final String carId; + + /// Nur wenn alle aktiven Lieferungen im Standardlager fertig beladen sind, + /// darf der Fahrer in die Auslieferungs-Phase wechseln. + final bool canStart; + + @override + Widget build(BuildContext context) { + if (carId.isEmpty) return const SizedBox.shrink(); + final theme = Theme.of(context); + return SafeArea( + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (!canStart) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.info_outline, + size: 16, + color: theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 6), + Flexible( + child: Text( + 'Erst alle Artikel des Standardlagers beladen', + style: TextStyle( + fontSize: 12, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + ), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: canStart + ? () { + context.read().add( + PhaseSet( + carId: carId, + phase: DeliveryPhase.ausliefern, + ), + ); + } + : null, + icon: const Icon(Icons.arrow_forward), + label: const Text('Auslieferungs-Phase starten'), + ), + ), + ], + ), + ), + ); + } +} + +/// UX-Bucket der Übersicht. Bestimmt, in welche Sektion eine Lieferung +/// einsortiert wird: +/// * `open` — Standardlager nicht fertig (= jetzt zu beladen) +/// * `later` — Standardlager fertig, aber Filial-Items offen +/// * `paused` — Lieferung explizit pausiert (`held`) +/// * `done` — komplett fertig (Standardlager fertig & kein Filiale) +/// * `canceled` — endgültig abgebrochen +enum _OverviewBucket { open, later, paused, done, canceled } + +/// Bündelt eine Lieferung mit ihrer Beladereihenfolge-Position für die +/// Übersicht — der `originalIndex` bleibt über die Sektions-Umsortierung +/// hinweg sichtbar im Tile-Avatar. +class _OverviewEntry { + const _OverviewEntry({ + required this.originalIndex, + required this.delivery, + required this.standardItems, + }); + + final int originalIndex; + final Delivery delivery; + final List standardItems; +} + +/// Sektion in der Übersicht mit eigenem Header + zugehörigen Tiles. +/// Header zeigt Titel, farbigen Pill mit Anzahl und nimmt die Section- +/// Farbe als Akzent — Fahrer erkennt auf einen Blick, „was kommt jetzt". +class _BucketSection extends StatelessWidget { + const _BucketSection({ + required this.title, + required this.count, + required this.color, + required this.entries, + required this.details, + this.icon, + }); + + final String title; + final int count; + final Color color; + final List<_OverviewEntry> entries; + final TourDetails details; + + /// Optionales Icon vor dem Titel — wird aktuell nur für „Nächste + /// Lieferung" verwendet, damit diese Sektion auf einen Blick als + /// „aktiv jetzt" erkennbar ist. Bei den restlichen Sektionen reichen + /// Farb-Balken + Pill als visueller Anker. + final IconData? icon; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 4), + child: Row( + children: [ + Container( + width: 4, + height: 18, + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: 8), + if (icon != null) ...[ + Icon(icon, size: 16, color: color), + const SizedBox(width: 6), + ], + Text( + title, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: color, + letterSpacing: 0.4, + ), + ), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 1), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + '$count', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: color, + ), + ), + ), + ], + ), + ), + for (final entry in entries) + _OverviewTile( + position: entry.originalIndex + 1, + delivery: entry.delivery, + standardItems: entry.standardItems, + details: details, + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => LoadingCustomerPage( + initialIndex: entry.originalIndex, + ), + ), + ); + }, + ), + ], + ); + } +} + +/// Hinweis-Badge unterhalb der Status-Zeile einer Lieferung mit Artikeln +/// aus einem oder mehreren Filialen. +/// +/// Formuliert bewusst „Enthält auch … zum späteren Abholen": Die Lieferung +/// wird JETZT normal (im Standardlager) beladen — das Filial-Item ist +/// nur ein zusätzlicher Bestandteil. Das „auch" signalisiert den Zusatz, +/// und „zum späteren Abholen" hängt den Zeitbezug klar an den ARTIKEL, nicht +/// an die Lieferung (die sonst fälschlich als nach-hinten-geschoben wirkt). class _ExternalWarehouseBadge extends StatelessWidget { const _ExternalWarehouseBadge({required this.labels}); @@ -373,33 +804,44 @@ class _ExternalWarehouseBadge extends StatelessWidget { @override Widget build(BuildContext context) { - final text = labels.isEmpty - ? "Außenlager" - : "Außenlager: ${labels.join(", ")}"; + final lagerText = labels.isEmpty + ? 'Artikel zum späteren Abholen aus der Filiale' + : 'Artikel zum späteren Abholen aus Filiale: ${labels.join(", ")}'; return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5), decoration: BoxDecoration( - color: Colors.deepOrange.withValues(alpha: 0.15), + color: Colors.amber.withValues(alpha: 0.22), borderRadius: BorderRadius.circular(6), - border: Border.all(color: Colors.deepOrange.withValues(alpha: 0.6)), + border: Border.all(color: Colors.amber.withValues(alpha: 0.7)), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.warehouse_outlined, - size: 14, color: Colors.deepOrange.shade700), + Icon( + Icons.warehouse_outlined, + size: 14, + color: Colors.amber.shade800, + ), const SizedBox(width: 4), Flexible( - child: Text( - text, - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: Colors.deepOrange.shade800, - ), + child: RichText( maxLines: 2, overflow: TextOverflow.ellipsis, + text: TextSpan( + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Colors.amber.shade800, + ), + children: [ + const TextSpan( + text: 'Enthält auch ', + style: TextStyle(fontWeight: FontWeight.w800), + ), + TextSpan(text: lagerText), + ], + ), ), ), ], @@ -421,7 +863,7 @@ class _EmptyOverview extends StatelessWidget { Icon(Icons.inbox_outlined, size: 64, color: scheme.onSurfaceVariant), const SizedBox(height: 12), Text( - "Keine Lieferungen zum Beladen", + 'Keine Lieferungen zum Beladen', style: Theme.of(context).textTheme.titleMedium, ), ], diff --git a/lib/feature/loading/util/loading_order.dart b/lib/feature/loading/util/loading_order.dart deleted file mode 100644 index 50f3d9d..0000000 --- a/lib/feature/loading/util/loading_order.dart +++ /dev/null @@ -1,68 +0,0 @@ -import 'package:hl_lieferservice/feature/delivery/bloc/tour_state.dart'; -import 'package:hl_lieferservice/model/delivery.dart'; - -/// Hilfen rund um die Belade-Reihenfolge. -/// -/// Die Beladereihenfolge ist die *Umkehrung* der vom Fahrer bestätigten -/// Auslieferungs-Reihenfolge (Sortier-Phase): wer zuletzt ausgeliefert wird, -/// kommt zuerst auf den LKW und liegt hinten. Diese Klasse liefert die -/// gefilterte und gespiegelte ID-Liste — Quelle ist immer -/// `TourLoaded.sortingInformation[carId]`. -/// -/// Filterung: -/// * Bei ≥2 Fahrzeugen im Team: nur Lieferungen mit -/// `delivery.carId == selectedCarId`. -/// * Bei genau 1 Fahrzeug: alle Tour-Lieferungen. -/// -/// Konsistent mit der bestehenden Logik in [DeliverySortPage] und der -/// alten `scan_page.dart:792`. -class LoadingOrder { - const LoadingOrder._(); - - /// Berechnet die Belade-Reihenfolge an Delivery-IDs. - /// - /// [carIdStr] ist das String-Pendant der gewählten Auto-ID, weil die - /// `sortingInformation` mit String-Keys arbeitet. - static List computeForCar({ - required TourLoaded state, - required String carIdStr, - }) { - final cars = state.tour.driver.cars; - final allowedIds = cars.length >= 2 - ? state.tour.deliveries - .where((d) => d.carId?.toString() == carIdStr) - .map((d) => d.id) - .toSet() - : state.tour.deliveries.map((d) => d.id).toSet(); - - final raw = state.sortingInformation[carIdStr] ?? const []; - - // Mit reversed nach hinten kommt die zuletzt ausgelieferte Lieferung - // nach vorne (zuerst beladen). - final reversed = raw.reversed.where(allowedIds.contains).toList(); - - // Falls die Sortierung leer ist (kann bei frisch geladener Tour - // vorkommen, bevor `EnsureSortingForCarEvent` durchlief), fallen wir - // auf die unsortierten Tour-IDs zurück — der Fahrer sieht so wenigstens - // alle Kunden, ohne dass die Page hängt. - if (reversed.isEmpty && allowedIds.isNotEmpty) { - return allowedIds.toList(growable: false); - } - return reversed; - } - - /// Komfort-Variante, die zusätzlich abgeschlossene Lieferungen rausfiltert - /// (für Anzeigen, die nur "noch zu beladen" bzw. aktive Einträge möchten). - static List computeActive({ - required TourLoaded state, - required String carIdStr, - }) { - final order = computeForCar(state: state, carIdStr: carIdStr); - final byId = {for (final d in state.tour.deliveries) d.id: d}; - return order.where((id) { - final d = byId[id]; - if (d == null) return false; - return d.state != DeliveryState.finished; - }).toList(growable: false); - } -} diff --git a/lib/feature/loading/widget/article_row.dart b/lib/feature/loading/widget/article_row.dart deleted file mode 100644 index aadfde2..0000000 --- a/lib/feature/loading/widget/article_row.dart +++ /dev/null @@ -1,535 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:hl_lieferservice/model/article.dart'; -import 'package:hl_lieferservice/model/component.dart'; - -/// Identifier-Helpers für den Hold-State-Set: ein Artikel ohne Komponenten -/// wird mit seiner [Article.internalId] referenziert, eine Komponente mit -/// `:`. -/// -/// Wir nutzen bewusst ein einfaches String-Schema statt einer eigenen Klasse, -/// weil der Set-Lookup in jedem Row-Rebuild stattfindet und Sets von -/// einfachen Strings am preisgünstigsten sind. -class HoldKey { - /// Schlüssel für einen ganzen (Nicht-Parent-)Artikel. - static String article(Article a) => "art:${a.internalId}"; - - /// Schlüssel für eine Komponente (Stücklisten-Position) unterhalb eines - /// Parent-Artikels. - static String component(Article parent, Component c) => - "comp:${parent.internalId}:${c.articleNumber}"; -} - -/// Visuelle Konstanten für die "Heute zurückgehalten"-Markierung. -const _holdBadgeColor = Colors.deepOrange; - -/// Renderer für eine Artikelzeile innerhalb der Beladen-Phase. -/// -/// Unterscheidet automatisch zwischen Parent-Artikel (Stückliste) und -/// regulärem Artikel — die Komponenten werden in einem [ParentArticleRow] -/// inkl. Liste von [ComponentRow] aufgeklappt dargestellt. Außerhalb dieser -/// Klasse sollte nur [ArticleRow] direkt verwendet werden; die anderen -/// beiden Widgets sind als Subkomponenten exportiert, falls jemand sie -/// gezielt ansteuern möchte. -class ArticleRow extends StatelessWidget { - const ArticleRow({ - super.key, - required this.article, - required this.isHeld, - required this.disabled, - this.heldComponents = const {}, - this.onTap, - this.onLongPress, - }); - - /// Der darzustellende Artikel. - final Article article; - - /// `true`, wenn der Artikel als Ganzes für heute zurückgehalten ist. - /// Bei Parent-Artikeln wird dies an die Komponenten weitergereicht. - final bool isHeld; - - /// `true`, wenn die Lieferung selbst (z. B. wegen Abbruch) deaktiviert - /// ist — die Zeile wird grundsätzlich ausgegraut, Tap deaktiviert. - final bool disabled; - - /// Set der gehaltenen Komponenten-Schlüssel (siehe [HoldKey.component]). - /// Wird nur ausgewertet, wenn der Artikel ein Parent ist. - final Set heldComponents; - - /// Optional: Tap-Callback, z. B. um den Artikel "manuell" zu inkrementieren. - /// Bleibt für die Beladen-Phase aktuell `null` — der Scan-Flow geht über - /// den Scanner, nicht den Tap. Lässt aber Raum für spätere Komfort-Aktionen. - final VoidCallback? onTap; - - /// Optional: Long-Press, z. B. für ein Kontext-Menü (Unscan). - final VoidCallback? onLongPress; - - @override - Widget build(BuildContext context) { - if (article.isParent && article.components.isNotEmpty) { - return ParentArticleRow( - article: article, - parentHeld: isHeld, - disabled: disabled, - heldComponents: heldComponents, - ); - } - return _RegularArticleRow( - article: article, - isHeld: isHeld, - disabled: disabled, - onTap: onTap, - onLongPress: onLongPress, - ); - } -} - -/// Reguläre Artikel-Zeile (ohne Stückliste) als Card. -class _RegularArticleRow extends StatelessWidget { - const _RegularArticleRow({ - required this.article, - required this.isHeld, - required this.disabled, - this.onTap, - this.onLongPress, - }); - - final Article article; - final bool isHeld; - final bool disabled; - final VoidCallback? onTap; - final VoidCallback? onLongPress; - - @override - Widget build(BuildContext context) { - final entryDone = article.isFullyScanned; - final theme = Theme.of(context); - final scheme = theme.colorScheme; - final effectiveDisabled = disabled || isHeld; - - // Card-Styling abhängig vom Status: gescannt = grünlicher Akzent, - // zurückgehalten = orange-Akzent, sonst neutral. So sieht der Fahrer - // beim Scrollen ohne Lesen, was schon erledigt ist. - final Color cardColor; - final Color borderColor; - final IconData leadingIcon; - final Color leadingColor; - - if (isHeld) { - cardColor = _holdBadgeColor.withValues(alpha: 0.07); - borderColor = _holdBadgeColor.withValues(alpha: 0.45); - leadingIcon = Icons.pause_circle_outline; - leadingColor = _holdBadgeColor; - } else if (entryDone) { - cardColor = Colors.green.withValues(alpha: 0.07); - borderColor = Colors.green.withValues(alpha: 0.45); - leadingIcon = Icons.check_circle; - leadingColor = Colors.green.shade700; - } else { - cardColor = scheme.surfaceContainerLow; - borderColor = scheme.outlineVariant.withValues(alpha: 0.4); - leadingIcon = Icons.inventory_2_outlined; - leadingColor = scheme.onSurfaceVariant; - } - - return Opacity( - opacity: effectiveDisabled ? 0.45 : 1.0, - child: Card( - margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), - elevation: 0, - color: cardColor, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - side: BorderSide(color: borderColor), - ), - child: InkWell( - onTap: effectiveDisabled ? null : onTap, - onLongPress: effectiveDisabled ? null : onLongPress, - borderRadius: BorderRadius.circular(12), - child: Padding( - padding: const EdgeInsets.fromLTRB(12, 10, 12, 10), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Container( - width: 36, - height: 36, - decoration: BoxDecoration( - color: leadingColor.withValues(alpha: 0.15), - shape: BoxShape.circle, - ), - child: Icon(leadingIcon, color: leadingColor, size: 20), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - article.name, - style: TextStyle( - fontSize: 15, - fontWeight: FontWeight.w600, - decoration: isHeld - ? TextDecoration.lineThrough - : TextDecoration.none, - ), - ), - const SizedBox(height: 2), - Text( - "Artikelnr. ${article.articleNumber}", - style: TextStyle( - fontSize: 12, - color: scheme.onSurfaceVariant, - ), - ), - ], - ), - ), - const SizedBox(width: 8), - _ScanCountBadge( - done: article.scannedAmount + article.scannedRemovedAmount, - total: article.amount, - isComplete: entryDone, - ), - ], - ), - if (isHeld) ...[ - const SizedBox(height: 8), - const _HeldBadge(), - ], - ], - ), - ), - ), - ), - ); - } -} - -/// Parent-Artikel (Stückliste) — zeigt eine Header-Zeile und darunter die -/// einzelnen Komponenten als [ComponentRow]. -class ParentArticleRow extends StatelessWidget { - const ParentArticleRow({ - super.key, - required this.article, - required this.parentHeld, - required this.disabled, - this.heldComponents = const {}, - }); - - /// Der Parent-Artikel (muss `isParent == true` und `components.isNotEmpty`). - final Article article; - - /// `true`, wenn der gesamte Parent-Artikel zurückgehalten ist - /// (vererbt sich auf alle Komponenten). - final bool parentHeld; - - /// `true`, wenn die Lieferung deaktiviert ist (z. B. abgebrochen). - final bool disabled; - - /// Set der gehaltenen Komponenten-Schlüssel (siehe [HoldKey.component]). - final Set heldComponents; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final scheme = theme.colorScheme; - final allDone = article.isFullyScanned; - final scannedCount = - article.components.where((c) => c.isFullyScanned).length; - final effectiveDisabled = disabled || parentHeld; - - // Card-Styling für Stückliste — gleiche Logik wie reguläre Artikel, - // aber mit Stücklisten-Icon und der Komponenten-Liste innerhalb derselben - // Card (visuell gruppiert). - final Color cardColor; - final Color borderColor; - final IconData headerIcon; - final Color headerIconColor; - - if (parentHeld) { - cardColor = _holdBadgeColor.withValues(alpha: 0.07); - borderColor = _holdBadgeColor.withValues(alpha: 0.45); - headerIcon = Icons.pause_circle_outline; - headerIconColor = _holdBadgeColor; - } else if (allDone) { - cardColor = Colors.green.withValues(alpha: 0.07); - borderColor = Colors.green.withValues(alpha: 0.45); - headerIcon = Icons.check_circle; - headerIconColor = Colors.green.shade700; - } else { - cardColor = scheme.surfaceContainerLow; - borderColor = scheme.outlineVariant.withValues(alpha: 0.4); - headerIcon = Icons.account_tree_outlined; - headerIconColor = scheme.primary; - } - - return Opacity( - opacity: effectiveDisabled ? 0.45 : 1.0, - child: Card( - margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), - elevation: 0, - color: cardColor, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - side: BorderSide(color: borderColor), - ), - child: Padding( - padding: const EdgeInsets.fromLTRB(12, 10, 12, 10), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // Header-Reihe mit Icon, Name, Komponenten-Counter. - Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Container( - width: 36, - height: 36, - decoration: BoxDecoration( - color: headerIconColor.withValues(alpha: 0.15), - shape: BoxShape.circle, - ), - child: - Icon(headerIcon, color: headerIconColor, size: 20), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - article.name, - style: TextStyle( - fontSize: 15, - fontWeight: FontWeight.w600, - decoration: parentHeld - ? TextDecoration.lineThrough - : TextDecoration.none, - ), - ), - const SizedBox(height: 2), - Text( - "Stückliste · $scannedCount/${article.components.length} Komponenten", - style: TextStyle( - fontSize: 12, - color: scheme.onSurfaceVariant, - ), - ), - ], - ), - ), - const SizedBox(width: 8), - Icon( - allDone ? Icons.check_circle : Icons.pending_outlined, - color: allDone ? Colors.green : Colors.orange, - size: 22, - ), - ], - ), - if (parentHeld) ...[ - const SizedBox(height: 8), - const _HeldBadge(), - ], - if (article.components.isNotEmpty) ...[ - const SizedBox(height: 10), - Divider( - height: 1, - color: scheme.outlineVariant.withValues(alpha: 0.6), - ), - const SizedBox(height: 6), - ...article.components.map( - (c) => ComponentRow( - component: c, - parentArticle: article, - isHeld: parentHeld || - heldComponents.contains(HoldKey.component(article, c)), - disabled: disabled, - ), - ), - ], - ], - ), - ), - ), - ); - } -} - -/// Eine einzelne Komponenten-Zeile (Position einer Stückliste). -class ComponentRow extends StatelessWidget { - const ComponentRow({ - super.key, - required this.component, - required this.parentArticle, - required this.isHeld, - required this.disabled, - }); - - /// Die Komponente. - final Component component; - - /// Parent-Artikel zur Auflösung des Hold-Keys & Anzeige-Kontextes. - final Article parentArticle; - - /// `true`, wenn diese Komponente (oder der Parent) zurückgehalten ist. - final bool isHeld; - - /// `true`, wenn die Lieferung deaktiviert ist. - final bool disabled; - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - final done = component.isFullyScanned; - final effectiveDisabled = disabled || isHeld; - - // Component-Reihe sitzt INNERHALB der Parent-Card — daher kein eigener - // Card-Wrapper. Stattdessen klare Einrückung + dezente Status-Markierung. - final Color iconColor = done - ? Colors.green.shade700 - : (isHeld ? _holdBadgeColor : scheme.onSurfaceVariant); - final IconData icon = isHeld - ? Icons.pause_circle_outline - : (done ? Icons.check_circle : Icons.radio_button_unchecked); - - return Opacity( - opacity: effectiveDisabled ? 0.45 : 1.0, - child: Padding( - padding: const EdgeInsets.fromLTRB(48, 6, 4, 6), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Row( - children: [ - Icon(icon, color: iconColor, size: 18), - const SizedBox(width: 10), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - component.name, - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w500, - decoration: isHeld - ? TextDecoration.lineThrough - : TextDecoration.none, - ), - ), - Text( - "Artikelnr. ${component.articleNumber}", - style: TextStyle( - fontSize: 11, - color: scheme.onSurfaceVariant, - ), - ), - ], - ), - ), - _ScanCountBadge( - done: component.scannedAmount, - total: component.requiredAmount, - isComplete: done, - compact: true, - ), - ], - ), - if (isHeld) ...[ - const SizedBox(height: 4), - const _HeldBadge(indented: true), - ], - ], - ), - ), - ); - } -} - -/// Kompaktes Mengen-Badge `x / y×` für Artikel-/Komponenten-Karten. -/// `compact: true` reduziert Padding und Schriftgröße für die Verwendung -/// innerhalb der Parent-Card. -class _ScanCountBadge extends StatelessWidget { - const _ScanCountBadge({ - required this.done, - required this.total, - required this.isComplete, - this.compact = false, - }); - - final int done; - final int total; - final bool isComplete; - final bool compact; - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - final color = isComplete ? Colors.green.shade700 : scheme.primary; - - return Container( - padding: EdgeInsets.symmetric( - horizontal: compact ? 8 : 10, - vertical: compact ? 3 : 5, - ), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(8), - ), - child: Text( - "$done / $total×", - style: TextStyle( - fontSize: compact ? 11 : 13, - fontWeight: FontWeight.bold, - color: color, - ), - ), - ); - } -} - -class _HeldBadge extends StatelessWidget { - const _HeldBadge({this.indented = false}); - - /// Linke Einrückung — für Komponenten unter dem Parent-Header in der Card. - final bool indented; - - @override - Widget build(BuildContext context) { - return Padding( - padding: EdgeInsets.only(left: indented ? 28 : 0), - child: Align( - alignment: Alignment.centerLeft, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: BoxDecoration( - color: _holdBadgeColor.withValues(alpha: 0.14), - borderRadius: BorderRadius.circular(6), - border: Border.all(color: _holdBadgeColor.withValues(alpha: 0.5)), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: const [ - Icon(Icons.pause_circle_outline, - size: 12, color: _holdBadgeColor), - SizedBox(width: 4), - Text( - "Heute zurückgehalten", - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: _holdBadgeColor, - ), - ), - ], - ), - ), - ), - ); - } -} diff --git a/lib/feature/loading/widget/hold_selection_dialog.dart b/lib/feature/loading/widget/hold_selection_dialog.dart deleted file mode 100644 index 4c49dc9..0000000 --- a/lib/feature/loading/widget/hold_selection_dialog.dart +++ /dev/null @@ -1,243 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:hl_lieferservice/feature/loading/widget/article_row.dart'; -import 'package:hl_lieferservice/model/article.dart'; -import 'package:hl_lieferservice/model/component.dart'; - -/// Eine einzelne, im Hold-Dialog auswählbare Position. Aufrufer erhalten -/// nach Bestätigung die ausgewählten Items zurück. -/// -/// Genau eines von [article] / [component] ist gesetzt — beide kombiniert -/// ergeben einen Komponenten-Eintrag (component != null mit zugehörigem -/// Parent-Artikel in [article]). -class HoldSelectionItem { - HoldSelectionItem.article(this.article) - : component = null, - key = HoldKey.article(article); - - HoldSelectionItem.component(this.article, Component this.component) - : key = HoldKey.component(article, component); - - /// Artikel — bei Komponenten der zugehörige Parent. - final Article article; - - /// Komponente (nur gesetzt, wenn es sich um eine Stücklisten-Position - /// handelt). - final Component? component; - - /// Eindeutiger Schlüssel zur Hold-State-Verwaltung. Identisch mit den - /// Keys, die [HoldKey] erzeugt — so kann ein Aufrufer ohne Umweg den - /// internen Hold-Set füllen. - final String key; - - String get _displayName => component?.name ?? article.name; - - String get _articleNumber => - component?.articleNumber ?? article.articleNumber; -} - -/// Auswahl-Dialog für den Teilabbruch ("Artikel heute nicht liefern"). -/// -/// Liefert nach Bestätigung per `Navigator.pop` die Liste der ausgewählten -/// [HoldSelectionItem]s. Bei Abbruch ist das Ergebnis `null`. Items, die -/// im Set [alreadyHeld] enthalten sind, werden ausgegraut dargestellt und -/// sind nicht erneut wählbar. -class HoldSelectionDialog extends StatefulWidget { - const HoldSelectionDialog({ - super.key, - required this.customerName, - required this.articles, - required this.alreadyHeld, - }); - - /// Anzeigename des Kunden — wird im Dialog-Header gezeigt. - final String customerName; - - /// Scannbare Artikel der Lieferung (also bereits vorgefiltert). - final List
articles; - - /// Set bereits gehaltener Keys — diese erscheinen ausgegraut & disabled. - final Set alreadyHeld; - - static Future?> show( - BuildContext context, { - required String customerName, - required List
articles, - required Set alreadyHeld, - }) { - return showDialog>( - context: context, - builder: (_) => HoldSelectionDialog( - customerName: customerName, - articles: articles, - alreadyHeld: alreadyHeld, - ), - ); - } - - @override - State createState() => _HoldSelectionDialogState(); -} - -class _HoldSelectionDialogState extends State { - final Set _selectedKeys = {}; - late final List _items; - - @override - void initState() { - super.initState(); - _items = _buildItems(widget.articles); - } - - /// Erzeugt aus den Artikeln die selektierbaren Einträge. Parent-Artikel - /// werden nicht selbst zum Eintrag — ihre Komponenten sind die wählbaren - /// Einheiten. Für die Anzeige der Header-Zeile werden Parents über das - /// Build-Verfahren (siehe build) separat eingestreut. - List _buildItems(List
articles) { - final result = []; - for (final a in articles) { - if (a.isParent && a.components.isNotEmpty) { - for (final c in a.components) { - result.add(HoldSelectionItem.component(a, c)); - } - } else { - result.add(HoldSelectionItem.article(a)); - } - } - return result; - } - - void _toggle(String key) { - setState(() { - if (_selectedKeys.contains(key)) { - _selectedKeys.remove(key); - } else { - _selectedKeys.add(key); - } - }); - } - - void _confirm() { - final selected = - _items.where((i) => _selectedKeys.contains(i.key)).toList(); - Navigator.of(context).pop(selected); - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - return AlertDialog( - title: const Text("Artikel zurückhalten"), - content: SizedBox( - width: double.maxFinite, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - widget.customerName, - style: theme.textTheme.bodyMedium?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 8), - const Text( - "Markiere die Positionen, die heute nicht ausgeliefert werden:", - style: TextStyle(fontSize: 13), - ), - const SizedBox(height: 8), - Flexible( - child: ListView( - shrinkWrap: true, - children: _buildList(theme), - ), - ), - ], - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text("Abbrechen"), - ), - FilledButton( - onPressed: _selectedKeys.isEmpty ? null : _confirm, - child: const Text("Weiter"), - ), - ], - ); - } - - /// Baut die ListView-Inhalte mit Header-Zeilen für Parent-Artikel. - /// Parent-Header sind bewusst nicht klickbar — sie dienen nur zur - /// Strukturierung. - List _buildList(ThemeData theme) { - final widgets = []; - - for (final a in widget.articles) { - if (a.isParent && a.components.isNotEmpty) { - widgets.add( - Padding( - padding: const EdgeInsets.only(top: 8, bottom: 2, left: 4), - child: Row( - children: [ - Icon(Icons.account_tree_outlined, - size: 16, color: theme.colorScheme.primary), - const SizedBox(width: 6), - Expanded( - child: Text( - a.name, - style: const TextStyle( - fontWeight: FontWeight.w600, - fontSize: 13, - ), - ), - ), - ], - ), - ), - ); - for (final c in a.components) { - final item = _items.firstWhere( - (i) => i.component == c && i.article == a, - ); - widgets.add(_buildTile(item, indent: true)); - } - } else { - final item = _items.firstWhere( - (i) => i.article == a && i.component == null, - ); - widgets.add(_buildTile(item)); - } - } - - return widgets; - } - - Widget _buildTile(HoldSelectionItem item, {bool indent = false}) { - final alreadyHeld = widget.alreadyHeld.contains(item.key); - final selected = _selectedKeys.contains(item.key); - - return Padding( - padding: EdgeInsets.only(left: indent ? 16 : 0), - child: Opacity( - opacity: alreadyHeld ? 0.4 : 1.0, - child: CheckboxListTile( - contentPadding: EdgeInsets.zero, - dense: true, - value: alreadyHeld ? true : selected, - onChanged: alreadyHeld ? null : (_) => _toggle(item.key), - title: Text( - item._displayName, - style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500), - ), - subtitle: Text( - "Artikelnr. ${item._articleNumber}" - "${alreadyHeld ? " · bereits zurückgehalten" : ""}", - style: const TextStyle(fontSize: 11), - ), - ), - ), - ); - } -} diff --git a/lib/feature/loading/widget/reason_catalog.dart b/lib/feature/loading/widget/reason_catalog.dart new file mode 100644 index 0000000..bbc6d61 --- /dev/null +++ b/lib/feature/loading/widget/reason_catalog.dart @@ -0,0 +1,46 @@ +/// Vordefinierte Reason-Listen pro Action-Typ. +/// +/// Bewusst hier zentral statt jede UI-Stelle ihren eigenen Strings +/// definieren zu lassen — Konsistenz für den Fahrer und einfache +/// nachträgliche Anpassung (eine Datei, ein PR). +/// +/// Jede Liste enthält die fachlich häufigsten Gründe. „Anderer Grund" +/// erscheint im Picker zusätzlich als Freitext-Fallback und ist nicht +/// Teil der Konstanten — der Picker fügt ihn selbst an. +class ReasonCatalog { + const ReasonCatalog._(); + + /// Gründe für `POST /deliveries/{id}/cancel`. Endgültig, deshalb + /// bewusst Gründe, die einen erneuten Liefer-Versuch sinnlos machen. + static const List deliveryCancel = [ + 'Adresse falsch', + 'Kunde unbekannt', + 'Kunde nicht erreichbar', + 'Termin endgültig verpasst', + 'Ware nicht verfügbar', + ]; + + /// Gründe für `POST /deliveries/{id}/hold`. Reversibel — typischerweise + /// „kommt später nochmal" oder „muss intern geklärt werden". + static const List deliveryHold = [ + 'Kunde nicht zu Hause', + 'Termin verschoben', + 'Wartet auf Rückruf', + ]; + + /// Gründe für `POST /scans action=remove`. Item wird aus der Lieferung + /// genommen, kommt nicht mit. + static const List itemRemove = [ + 'Artikel defekt', + 'Artikel nicht vorhanden', + 'Falscher Artikel im Lager', + 'Falsch gescannt', + ]; + + /// Gründe für `POST /scans action=hold`. Item kurzfristig zurückgestellt. + static const List itemHold = [ + 'Lager findet Ware nicht', + 'Ware wird geprüft', + 'Zusatzklärung nötig', + ]; +} diff --git a/lib/feature/loading/widget/reason_picker_dialog.dart b/lib/feature/loading/widget/reason_picker_dialog.dart deleted file mode 100644 index fa743a6..0000000 --- a/lib/feature/loading/widget/reason_picker_dialog.dart +++ /dev/null @@ -1,140 +0,0 @@ -import 'package:flutter/material.dart'; - -/// Vordefinierte Gründe für Abbruch / Teilabbruch. -/// -/// Die Liste ist absichtlich kurz und fahrernah gehalten — wir fragen keine -/// Mini-Romane ab, sondern erlauben das Wichtigste mit einem Tap. Für alles -/// Sonstige steht "Anderer Grund" mit Freitext zur Verfügung. -const List _predefinedReasons = [ - "Kunde nicht erreichbar", - "Adresse falsch", - "Ware beschädigt", - "Zugang nicht möglich", - "Anderer Grund", -]; - -/// Schlüssel-Konstante für die "Anderer Grund"-Option — damit Aufrufer den -/// Vergleich nicht über String-Literals führen müssen. -const String _otherReasonOption = "Anderer Grund"; - -/// Wiederverwendbarer Grund-Dialog für Beladen-Phase: sowohl der komplette -/// Lieferungs-Abbruch als auch das Zurückhalten einzelner Artikel / -/// Komponenten landen in diesem Picker. -/// -/// Liefert per `showDialog` den finalen Grundtext zurück — also -/// entweder einen der vordefinierten Strings oder den vom Fahrer -/// eingegebenen Freitext. Bei Abbruch des Dialogs ist das Ergebnis `null`. -class ReasonPickerDialog extends StatefulWidget { - const ReasonPickerDialog({ - super.key, - required this.title, - this.subtitle, - }); - - /// Anzeigetitel des Dialogs (z. B. "Lieferung abbrechen"). - final String title; - - /// Optionaler erläuternder Untertitel (z. B. Name des Kunden). - final String? subtitle; - - /// Komfort-Helfer: zeigt den Dialog und liefert das Ergebnis. Aufrufer - /// müssen so nicht mehr selbst `showDialog` mit dem Builder - /// instanziieren. - static Future show( - BuildContext context, { - required String title, - String? subtitle, - }) { - return showDialog( - context: context, - builder: (_) => ReasonPickerDialog(title: title, subtitle: subtitle), - ); - } - - @override - State createState() => _ReasonPickerDialogState(); -} - -class _ReasonPickerDialogState extends State { - String? _selected; - final TextEditingController _freeText = TextEditingController(); - - @override - void dispose() { - _freeText.dispose(); - super.dispose(); - } - - bool get _isOther => _selected == _otherReasonOption; - - bool get _canConfirm { - if (_selected == null) return false; - if (_isOther) return _freeText.text.trim().isNotEmpty; - return true; - } - - void _confirm() { - if (!_canConfirm) return; - final reason = _isOther ? _freeText.text.trim() : _selected!; - Navigator.of(context).pop(reason); - } - - @override - Widget build(BuildContext context) { - return AlertDialog( - title: Text(widget.title), - content: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (widget.subtitle != null) ...[ - Text( - widget.subtitle!, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 12), - ], - ..._predefinedReasons.map((reason) { - return RadioListTile( - contentPadding: EdgeInsets.zero, - dense: true, - title: Text(reason), - value: reason, - groupValue: _selected, - onChanged: (val) => setState(() => _selected = val), - ); - }), - if (_isOther) - Padding( - padding: const EdgeInsets.only(top: 4), - child: TextField( - controller: _freeText, - autofocus: true, - maxLines: 3, - minLines: 2, - decoration: const InputDecoration( - labelText: "Bitte Grund angeben", - border: OutlineInputBorder(), - ), - onChanged: (_) => setState(() {}), - ), - ), - ], - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text("Abbrechen"), - ), - FilledButton( - onPressed: _canConfirm ? _confirm : null, - child: const Text("Bestätigen"), - ), - ], - ); - } -} diff --git a/lib/feature/loading/widget/reason_picker_sheet.dart b/lib/feature/loading/widget/reason_picker_sheet.dart new file mode 100644 index 0000000..20cb0f6 --- /dev/null +++ b/lib/feature/loading/widget/reason_picker_sheet.dart @@ -0,0 +1,225 @@ +import 'package:flutter/material.dart'; + +/// Ergebnis des Reason-Pickers: getrimmter Grund + optionale Menge. +class ReasonPickerResult { + const ReasonPickerResult({required this.reason, this.quantity}); + + final String reason; + + /// Gewählte Menge (1…maxQuantity). `null`, wenn das Sheet **ohne** + /// Mengen-Auswahl geöffnet wurde (`maxQuantity == null`, z. B. bei + /// Lieferungs-Gründen wie Pausieren/Abbrechen) — dann gilt „ganze Zeile". + final int? quantity; +} + +/// Bottom-Sheet zur Auswahl einer Begründung. Zeigt eine Radio-Liste der +/// vordefinierten Gründe + den Sonderfall „Anderer Grund", der ein +/// Freitext-Feld einblendet. +/// +/// Optional — wenn [maxQuantity] gesetzt und > 1 — zusätzlich eine +/// **Mengen-Auswahl** (Stepper 1…maxQuantity, Vorbelegung = maxQuantity) für +/// die Teilmengen-Löschung/-Gutschrift. +/// +/// Rückgabe: [ReasonPickerResult] oder `null` bei Abbruch. +/// +/// Validierung: +/// * Vordefinierter Grund → direkt OK +/// * „Anderer Grund" mit leerem Freitext → Bestätigen disabled +Future showReasonPickerSheet({ + required BuildContext context, + required String title, + required List presets, + String confirmLabel = 'Übernehmen', + int? maxQuantity, +}) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + showDragHandle: true, + builder: (ctx) => _ReasonPickerSheet( + title: title, + presets: presets, + confirmLabel: confirmLabel, + maxQuantity: maxQuantity, + ), + ); +} + +class _ReasonPickerSheet extends StatefulWidget { + const _ReasonPickerSheet({ + required this.title, + required this.presets, + required this.confirmLabel, + required this.maxQuantity, + }); + + final String title; + final List presets; + final String confirmLabel; + final int? maxQuantity; + + @override + State<_ReasonPickerSheet> createState() => _ReasonPickerSheetState(); +} + +/// Sentinel-Wert für die „Anderer Grund"-Option. Eigene Klasse, damit der +/// Vergleich nicht mit echten Reason-Strings kollidieren kann (z. B. +/// jemand definiert tatsächlich „Anderer Grund" als Standard-Reason). +const String _otherSentinel = ' other'; + +class _ReasonPickerSheetState extends State<_ReasonPickerSheet> { + String? _selected; + final _customController = TextEditingController(); + final _customFocus = FocusNode(); + late int _qty; + + @override + void initState() { + super.initState(); + // Vorbelegung: ganze Restmenge. + _qty = widget.maxQuantity ?? 1; + } + + @override + void dispose() { + _customController.dispose(); + _customFocus.dispose(); + super.dispose(); + } + + bool get _isOther => _selected == _otherSentinel; + + /// Mengen-Stepper nur zeigen, wenn überhaupt eine Auswahl Sinn ergibt. + bool get _showQuantity => (widget.maxQuantity ?? 0) > 1; + + String? get _effectiveReason { + if (_selected == null) return null; + if (_isOther) { + final v = _customController.text.trim(); + return v.isEmpty ? null : v; + } + return _selected; + } + + void _onConfirm() { + final reason = _effectiveReason; + if (reason == null) return; + Navigator.of(context).pop( + ReasonPickerResult( + reason: reason, + // Ohne Mengen-Kontext (maxQuantity == null) → null = ganze Zeile. + quantity: widget.maxQuantity == null ? null : _qty, + ), + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return SafeArea( + child: Padding( + padding: EdgeInsets.only( + left: 16, + right: 16, + top: 8, + // Tastatur-Höhe rein-clipping, damit das Freitext-Feld sichtbar bleibt. + bottom: MediaQuery.of(context).viewInsets.bottom + 16, + ), + // Scrollbar, damit Mengen-Stepper + Gründe + Freitext bei wenig Platz + // (offene Tastatur) nicht overflowen. + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text( + widget.title, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + if (_showQuantity) ...[ + Text( + 'Wie viele entfernen? (1–${widget.maxQuantity})', + style: theme.textTheme.bodySmall, + ), + const SizedBox(height: 4), + Row( + children: [ + IconButton.filled( + onPressed: + _qty > 1 ? () => setState(() => _qty -= 1) : null, + icon: const Icon(Icons.remove), + ), + Expanded( + child: Text( + '$_qty', + textAlign: TextAlign.center, + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + IconButton.filled( + onPressed: _qty < widget.maxQuantity! + ? () => setState(() => _qty += 1) + : null, + icon: const Icon(Icons.add), + ), + ], + ), + const Divider(height: 24), + ], + for (final preset in widget.presets) + RadioListTile( + value: preset, + groupValue: _selected, + onChanged: (v) => setState(() => _selected = v), + title: Text(preset), + contentPadding: EdgeInsets.zero, + dense: true, + ), + RadioListTile( + value: _otherSentinel, + groupValue: _selected, + onChanged: (v) { + setState(() => _selected = v); + WidgetsBinding.instance.addPostFrameCallback((_) { + _customFocus.requestFocus(); + }); + }, + title: const Text('Anderer Grund'), + contentPadding: EdgeInsets.zero, + dense: true, + ), + if (_isOther) + Padding( + padding: const EdgeInsets.only(top: 8, bottom: 8), + child: TextField( + controller: _customController, + focusNode: _customFocus, + autocorrect: false, + decoration: const InputDecoration( + border: OutlineInputBorder(), + hintText: 'Begründung', + ), + onChanged: (_) => setState(() {}), + textInputAction: TextInputAction.done, + onSubmitted: (_) => _onConfirm(), + ), + ), + const SizedBox(height: 8), + FilledButton( + onPressed: _effectiveReason == null ? null : _onConfirm, + child: Text(widget.confirmLabel), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/feature/payment_methods/bloc/payment_methods_cubit.dart b/lib/feature/payment_methods/bloc/payment_methods_cubit.dart new file mode 100644 index 0000000..ef4db47 --- /dev/null +++ b/lib/feature/payment_methods/bloc/payment_methods_cubit.dart @@ -0,0 +1,74 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'package:hl_lieferservice/domain/entity/payment_method.dart'; +import 'package:hl_lieferservice/domain/repository/payment_methods_repository.dart'; + +/// Cubit-State der Zahlungsmethoden-Stammdaten. +sealed class PaymentMethodsState { + const PaymentMethodsState(); +} + +class PaymentMethodsInitial extends PaymentMethodsState { + const PaymentMethodsInitial(); +} + +class PaymentMethodsLoading extends PaymentMethodsState { + const PaymentMethodsLoading(); +} + +class PaymentMethodsLoaded extends PaymentMethodsState { + const PaymentMethodsLoaded(this.methods); + + /// Alle aktiven Methoden (Backend filtert standardmäßig `includeInactive=false`). + /// Reihenfolge ist die vom Server gelieferte — der sortiert nach `createdAt`. + final List methods; + + /// Lookup nach `id`. Liefert `null` für historische Lieferungen, deren + /// referenzierte Methode inzwischen deaktiviert und damit aus der Liste + /// gefallen ist — der Aufrufer muss diesen Fall im UI abfangen. + PaymentMethod? byId(String id) { + for (final m in methods) { + if (m.id == id) return m; + } + return null; + } +} + +class PaymentMethodsFailed extends PaymentMethodsState { + const PaymentMethodsFailed(this.message); + final String message; +} + +/// Lädt die Zahlungsmethoden-Stammdaten einmal beim Login und stellt sie +/// als globalen Lookup zur Verfügung. +/// +/// Bewusst Cubit statt Bloc: keine Events, nur Refresh — und der Lookup +/// (`PaymentMethodsLoaded.byId`) ist die zentrale UI-Konsumstelle. Cached +/// die Liste so lange, bis explizit `refresh()` aufgerufen wird, weil +/// die Stammdaten sich tagsüber praktisch nie ändern. +class PaymentMethodsCubit extends Cubit { + PaymentMethodsCubit({required this.repository}) + : super(const PaymentMethodsInitial()); + + final PaymentMethodsRepository repository; + + Future load() async { + if (state is PaymentMethodsLoading) return; + emit(const PaymentMethodsLoading()); + try { + final methods = await repository.list(); + emit(PaymentMethodsLoaded(methods)); + } catch (e, st) { + debugPrint('PaymentMethodsCubit.load fehlgeschlagen: $e\n$st'); + final message = e is PaymentMethodsRepositoryException + ? e.message + : 'Zahlungsmethoden konnten nicht geladen werden'; + emit(PaymentMethodsFailed(message)); + } + } + + /// Explizit erneut vom Server holen — UI ruft das auf, falls die User + /// die Stammdaten zwischendurch im Verwaltungs-Screen geändert hat. + Future refresh() => load(); +} diff --git a/lib/feature/scan/model/article.dart b/lib/feature/scan/model/article.dart deleted file mode 100644 index 5ccffe9..0000000 --- a/lib/feature/scan/model/article.dart +++ /dev/null @@ -1,21 +0,0 @@ -import 'package:hl_lieferservice/model/delivery.dart'; - -class ArticleGroup { - final String articleName; - final String articleNumber; - final String internalRowId; - int totalCount; - int scannedCount; - Set deliveryIds; - - ArticleGroup({ - required this.articleName, - required this.internalRowId, - required this.articleNumber, - required this.totalCount, - required this.deliveryIds, - this.scannedCount = 0, - }); - - bool get isComplete => scannedCount >= totalCount; -} \ No newline at end of file diff --git a/lib/feature/scan/presentation/scanner.dart b/lib/feature/scan/presentation/scanner.dart deleted file mode 100644 index a0badbc..0000000 --- a/lib/feature/scan/presentation/scanner.dart +++ /dev/null @@ -1,77 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:mobile_scanner/mobile_scanner.dart'; - -/// StatefulWidget für den Barcode-Scanner mit grünem Border-Feedback -class BarcodeScannerWidget extends StatefulWidget { - final Function(String) onBarcodeDetected; - - const BarcodeScannerWidget({ - super.key, - required this.onBarcodeDetected, - }); - - @override - State createState() => _BarcodeScannerWidgetState(); -} - -class _BarcodeScannerWidgetState extends State { - bool _isDetected = false; - DateTime? _lastScannedTime; - final Duration _scanTimeout = const Duration(milliseconds: 2000); // 2 Sekunden Cooldown - - void _handleBarcodeDetected(String barcode) { - final now = DateTime.now(); - - // Prüfe ob genug Zeit seit dem letzten erfolgreichen Scan vergangen ist - if (_lastScannedTime != null && - now.difference(_lastScannedTime!).inMilliseconds < _scanTimeout.inMilliseconds) { - // Timeout nicht abgelaufen - ignoriere diesen Scan - debugPrint('Scan ignoriert - Cooldown aktiv'); - return; - } - - // Update letzte Scan-Zeit - _lastScannedTime = now; - - // Rand grün färben - setState(() { - _isDetected = true; - }); - - // Nach 500ms wieder zurücksetzen - Future.delayed(const Duration(milliseconds: 500), () { - if (mounted) { - setState(() { - _isDetected = false; - }); - } - }); - - // Callback aufrufen - widget.onBarcodeDetected(barcode); - } - - @override - Widget build(BuildContext context) { - return Container( - height: 150, - decoration: BoxDecoration( - border: Border.all( - color: _isDetected ? Colors.green : Colors.grey, - width: _isDetected ? 4 : 2, - ), - ), - child: MobileScanner( - onDetect: (capture) { - final List barcodes = capture.barcodes; - - for (final barcode in barcodes) { - if (barcode.rawValue != null) { - _handleBarcodeDetected(barcode.rawValue!); - } - } - }, - ), - ); - } -} \ No newline at end of file diff --git a/lib/feature/scan/repository/scan_repository.dart b/lib/feature/scan/repository/scan_repository.dart deleted file mode 100644 index b2880b6..0000000 --- a/lib/feature/scan/repository/scan_repository.dart +++ /dev/null @@ -1,11 +0,0 @@ -import 'package:hl_lieferservice/feature/scan/service/scan_service.dart'; - -class ScanRepository { - ScanService service; - - ScanRepository({required this.service}); - - Future scanArticle(String internalArticleId) async { - return await service.scanArticle(internalArticleId); - } -} diff --git a/lib/feature/scan/service/scan_service.dart b/lib/feature/scan/service/scan_service.dart deleted file mode 100644 index d6c59ba..0000000 --- a/lib/feature/scan/service/scan_service.dart +++ /dev/null @@ -1,41 +0,0 @@ -import 'dart:convert'; -import 'dart:io'; - -import 'package:flutter/material.dart'; -import 'package:hl_lieferservice/dto/scan_response.dart'; -import 'package:http/http.dart' as http; -import '../../../util.dart'; -import '../../authentication/exceptions.dart'; - -class ScanService { - Future scanArticle(String internalId) async { - try { - var response = await http.post( - urlBuilder("_web_scanArticle"), - headers: getSessionOrThrow(), - body: {"internal_id": internalId}, - ); - - debugPrint(jsonEncode({"internal_id": internalId})); - - if (response.statusCode == HttpStatus.unauthorized) { - throw UserUnauthorized(); - } - - Map responseJson = jsonDecode(response.body); - debugPrint(responseJson.toString()); - ScanResponseDTO responseDto = ScanResponseDTO.fromJson( - responseJson, - ); - - if (responseDto.succeeded == true) { - return; - } else { - debugPrint("ERROR: ${responseDto.message}"); - throw responseDto.message; - } - } catch (e) { - rethrow; - } - } -} \ No newline at end of file diff --git a/lib/feature/scan/util.dart b/lib/feature/scan/util.dart deleted file mode 100644 index 007edab..0000000 --- a/lib/feature/scan/util.dart +++ /dev/null @@ -1,35 +0,0 @@ -import 'package:hl_lieferservice/feature/scan/model/article.dart'; - -import '../../model/delivery.dart'; - -Map initializeArticleGroups(List deliveries) { - Map articleGroups = {}; - - // Alle Artikel aus allen Lieferungen durchgehen - for (var delivery in deliveries) { - for (var article in delivery.articles) { - if (articleGroups.containsKey(article.articleNumber)) { - // Artikel bereits vorhanden, Anzahl erhöhen - if (article.scannable) { - articleGroups[article.articleNumber]!.scannedCount += article.scannedAmount; - articleGroups[article.articleNumber]!.totalCount += article.amount; - articleGroups[article.articleNumber]!.deliveryIds.add(delivery); - } - } else { - if (article.scannable) { - // Neuer Artikel, hinzufügen - articleGroups[article.articleNumber] = ArticleGroup( - deliveryIds: {delivery}, - articleName: article.name, - articleNumber: article.articleNumber, - scannedCount: article.scannedAmount, - internalRowId: article.internalId.toString(), - totalCount: article.amount, - ); - } - } - } - } - - return articleGroups; -} \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index 2c745c0..cb2d848 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -3,6 +3,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:get_it/get_it.dart'; import 'package:hl_lieferservice/bloc/app_bloc.dart'; import 'package:hl_lieferservice/bloc/app_events.dart'; +import 'package:hl_lieferservice/data/cache/attachment_cache.dart'; import 'package:hl_lieferservice/data/network/network_locator.dart'; import 'package:hl_lieferservice/feature/settings/bloc/settings_bloc.dart'; import 'package:hl_lieferservice/feature/settings/bloc/settings_event.dart'; @@ -17,6 +18,11 @@ void main() { // verfügbar ist. registerNetworking(locator: locator); + // Persistenter Vorschau-Cache für Attachment-Bilder. Über die gesamte + // App-Lebensdauer stabil und zustandslos (das Verzeichnis löst er lazy + // selbst auf), daher hier als Singleton. + locator.registerSingleton(AttachmentCache()); + runApp(MultiBlocProvider(providers: [ BlocProvider(create: (context) => AppBloc(),), BlocProvider(create: (context) => SettingsBloc()) diff --git a/lib/model/address.dart b/lib/model/address.dart deleted file mode 100644 index 767e183..0000000 --- a/lib/model/address.dart +++ /dev/null @@ -1,24 +0,0 @@ -import 'package:hl_lieferservice/dto/address.dart'; - -class Address { - const Address( - {required this.street, - required this.city, - required this.postalCode}); - - final String street; - final String postalCode; - final String city; - - factory Address.fromDTO(AddressDTO dto) { - return Address( - street: dto.streetName, - city: dto.city, - postalCode: dto.postalCode); - } - - @override - String toString() { - return "$street $postalCode $city"; - } -} diff --git a/lib/model/article.dart b/lib/model/article.dart deleted file mode 100644 index 3a325ed..0000000 --- a/lib/model/article.dart +++ /dev/null @@ -1,106 +0,0 @@ -import 'package:hl_lieferservice/dto/article.dart'; - -import 'component.dart'; - -class Article { - Article({ - required this.name, - required this.articleNumber, - required this.amount, - required this.internalId, - required this.price, - required this.tax, - required this.scannable, - required this.scannedAmount, - required this.scannedRemovedAmount, - required this.isParent, - this.components = const [], - this.scannedDate, - this.removeNoteId, - this.warehouseNr, - this.warehouseName, - }); - - final String name; - final String articleNumber; - final int internalId; - final bool isParent; - final List components; - final String? warehouseNr; - final String? warehouseName; - - int amount; - double price; - double tax; - bool scannable; - DateTime? scannedDate; - String? removeNoteId; - int scannedAmount; - int scannedRemovedAmount; - - double getGrossPrice() { - return price * amount * ((100 + tax) / 100); - } - - double getGrossPriceScanned() { - return price * scannedAmount * ((100 + tax) / 100); - } - - /// Whether this article is fully scanned. - /// - /// For parent articles (Stückliste): delegates to components — all must be - /// individually scanned. For regular articles: the classic amount check. - bool get isFullyScanned { - if (isParent && components.isNotEmpty) { - return components.every((c) => c.isFullyScanned); - } - return scannedAmount + scannedRemovedAmount >= amount; - } - - /// Find a component by its article number, or `null` if none matches. - Component? findComponent(String articleNumber) { - for (final c in components) { - if (c.articleNumber == articleNumber) return c; - } - return null; - } - - /// Whether this article *or* any of its components carries [articleNumber]. - bool hasArticleNumber(String articleNumber) { - if (this.articleNumber == articleNumber) return true; - return components.any((c) => c.articleNumber == articleNumber); - } - - bool unscanned() { - if (isParent && components.isNotEmpty) { - return components.every((c) => c.scannedAmount == 0); - } - return scannedAmount == 0; - } - - int getScannedAmount() { - return scannedAmount; - } - - factory Article.fromDTO(ArticleDTO dto) { - return Article( - name: dto.name, - scannedAmount: int.parse( - dto.scannedAmount != "" ? dto.scannedAmount : "0", - ), - scannedRemovedAmount: int.tryParse(dto.scannedRemovedAmount) ?? 0, - removeNoteId: dto.removeNoteId, - internalId: int.parse(dto.internalId), - articleNumber: dto.articleNr, - amount: int.parse(dto.quantity == "" ? "0" : dto.quantity), - price: double.parse(dto.price == "" ? "0.0" : dto.price), - scannable: dto.scannable, - tax: double.parse(dto.taxRate == "" ? "19" : dto.taxRate), - isParent: dto.isParent, - components: dto.components?.map(Component.fromDTO).toList() ?? [], - warehouseNr: dto.warehouseNr?.isEmpty ?? true ? null : dto.warehouseNr, - warehouseName: - dto.warehouseName?.isEmpty ?? true ? null : dto.warehouseName, - ); - } -} diff --git a/lib/model/car.dart b/lib/model/car.dart deleted file mode 100644 index 76ad51e..0000000 --- a/lib/model/car.dart +++ /dev/null @@ -1,6 +0,0 @@ -/// Backward-compat-Re-Export: alle Aufrufer, die noch -/// `package:hl_lieferservice/model/car.dart` importieren, bekommen -/// jetzt die neue Domain-Entity. Mit Phase D wandert dieses File raus, -/// sobald die letzten Legacy-Aufrufer (tour_service.dart, -/// tour_event.dart) auf die echte Domain-Schicht umgestellt sind. -export 'package:hl_lieferservice/domain/entity/car.dart'; diff --git a/lib/model/component.dart b/lib/model/component.dart deleted file mode 100644 index 7c7694b..0000000 --- a/lib/model/component.dart +++ /dev/null @@ -1,34 +0,0 @@ -import 'package:hl_lieferservice/dto/component.dart'; - -class Component { - Component({ - required this.articleNumber, - required this.name, - required this.quantity, - required this.position, - this.scannedAmount = 0, - }); - - final String articleNumber; - final String name; - final double quantity; - final double position; - - int scannedAmount; - - /// Required scan count derived from BOM quantity (e.g. 7.0 → 7). - int get requiredAmount => quantity.ceil(); - - bool get isFullyScanned => scannedAmount >= requiredAmount; - - bool get needsScanning => scannedAmount < requiredAmount; - - factory Component.fromDTO(ComponentDTO dto) { - return Component( - articleNumber: dto.articleNr, - name: dto.name, - quantity: double.tryParse(dto.quantity) ?? 0.0, - position: double.tryParse(dto.pos) ?? 0.0, - ); - } -} diff --git a/lib/model/customer.dart b/lib/model/customer.dart deleted file mode 100644 index a980a69..0000000 --- a/lib/model/customer.dart +++ /dev/null @@ -1,19 +0,0 @@ -import 'package:hl_lieferservice/dto/customer.dart'; - -import 'address.dart'; - -class Customer { - const Customer({required this.name, required this.address, this.email}); - - final String name; - final Address address; - final String? email; - - factory Customer.fromDTO(CustomerDTO dto) { - return Customer( - name: dto.name, - address: Address.fromDTO(dto.address), - email: dto.eMail, - ); - } -} diff --git a/lib/model/delivery.dart b/lib/model/delivery.dart deleted file mode 100644 index 1be938a..0000000 --- a/lib/model/delivery.dart +++ /dev/null @@ -1,398 +0,0 @@ -import 'dart:typed_data'; - -import 'package:hl_lieferservice/dto/contact_person.dart'; -import 'package:hl_lieferservice/dto/delivery.dart'; -import 'package:hl_lieferservice/dto/image_note_response.dart'; -import 'package:hl_lieferservice/dto/note.dart'; -import 'package:hl_lieferservice/dto/note_template.dart'; -import 'package:hl_lieferservice/model/tour.dart'; -import 'package:hl_lieferservice/util.dart'; -import 'article.dart'; -import 'customer.dart'; - -class PaymentOptions { - PaymentOptions({this.value = 0.0, this.selectedPaymentMethod = "none"}); - - double value; - String selectedPaymentMethod; -} - -class Note { - Note({required this.content, required this.id}); - - final int id; - String content; - - factory Note.fromDto(NoteDTO dto) { - return Note(content: dto.note, id: int.parse(dto.id)); - } -} - -class NoteTemplate { - NoteTemplate({ - required this.title, - required this.content, - required this.language, - }); - - String title; - String content; - String language; - - factory NoteTemplate.fromDTO(NoteTemplateDTO dto) { - return NoteTemplate( - title: dto.title, - content: dto.note, - language: dto.language, - ); - } -} - -class ContactPerson { - const ContactPerson({required this.name, required this.phoneNumber}); - - final String name; - final String? phoneNumber; - - factory ContactPerson.fromDTO(ContactPersonDTO dto) { - String phone; - String phoneRegex = r'([0-9]+|[0-9]+(-|\/))[0-9]+'; - - if (dto.phoneNo == "" && dto.mobileNo != "") { - phone = dto.mobileNo; - } else if (dto.phoneNo != "" && dto.mobileNo == "") { - phone = dto.phoneNo; - } else { - phone = dto.mobileNo; - } - - return ContactPerson( - name: dto.name, - phoneNumber: concatenateRegexMatches(phone, phoneRegex), - ); - } -} - -enum DeliveryState { canceled, finished, ongoing, onhold } - -class Discount { - Discount({this.note, this.noteId, required this.article}); - - String? note; - String? noteId; - - /// The article refers to the special discount article such as "GUTSCHRIFT10" - /// for example. - Article article; -} - -class ImageNote { - ImageNote({ - required this.name, - required this.url, - required this.objectId, - this.data - }); - - final String name; - final String url; - final String objectId; - Uint8List? data; - - factory ImageNote.fromDTO(ImageNoteDTO dto) { - return ImageNote(name: dto.name, url: dto.url, objectId: dto.oid); - } - - factory ImageNote.make(String objectId, String name, Uint8List? bytes) { - String url = "/v1/preview/1920_1080_100_png/$objectId"; - - return ImageNote(name: name, url: url, objectId: objectId, data: bytes); - } -} - -class DeliveryOption { - DeliveryOption({ - required this.numerical, - required this.value, - required this.display, - required this.key, - }); - - bool numerical; - String value; - String display; - String key; - - factory DeliveryOption.fromDTO(DeliveryOptionDTO dto) { - return DeliveryOption( - numerical: dto.numerical, - value: dto.value, - display: dto.display, - key: dto.key, - ); - } - - dynamic getValue() { - if (!numerical) { - if (value.isEmpty) { - return false; - } else { - return value == "0" ? false : true; - } - } else { - if (value.isEmpty) { - return 0; - } else { - return int.parse(value); - } - } - } - - DeliveryOption copyWith({ - bool? numerical, - String? value, - String? display, - String? key, - }) { - return DeliveryOption( - numerical: numerical ?? this.numerical, - value: value ?? this.value, - display: display ?? this.display, - key: key ?? this.key, - ); - } -} - -class Delivery implements Comparable { - Delivery({ - required this.customer, - required this.id, - required this.articles, - required this.paymentOptions, - required this.notes, - required this.price, - required this.filePaths, - required this.currency, - required this.images, - required this.prepayment, - required this.paymentAtDelivery, - required this.payment, - required this.options, - this.state = DeliveryState.ongoing, - this.contactPerson, - required this.totalGrossValue, - required this.totalNetValue, - this.desiredTime, - this.discount, - this.specialAgreements, - this.carId, - }); - - final Customer customer; - final String id; - List
articles; - final ContactPerson? contactPerson; - final double price; - final String currency; - double totalGrossValue; - double totalNetValue; - String? desiredTime; - List filePaths; - Discount? discount; - DeliveryState state; - String? specialAgreements; - PaymentOptions paymentOptions; - /// UUID des zugewiesenen Fahrzeugs (Backend-Konvention). - /// War vor der Backend-Migration `int?` — die Konversion ist Teil - /// der Phase-D-Vorbereitung. - String? carId; - List notes; - List images; - double prepayment; - double paymentAtDelivery; - Payment payment; - List options; - - @override - int compareTo(Delivery other) { - return customer.name.compareTo(other.customer.name); - } - - Delivery copyWith({ - Customer? customer, - String? id, - List
? articles, - ContactPerson? contactPerson, - double? price, - String? currency, - double? totalGrossValue, - double? totalNetValue, - String? desiredTime, - List? filePaths, - Discount? discount, - DeliveryState? state, - String? specialAgreements, - PaymentOptions? paymentOptions, - String? carId, - List? notes, - List? images, - double? prepayment, - double? paymentAtDelivery, - Payment? payment, - List? options, - }) { - return Delivery( - customer: customer ?? this.customer, - id: id ?? this.id, - articles: articles ?? this.articles, - contactPerson: contactPerson ?? this.contactPerson, - price: price ?? this.price, - currency: currency ?? this.currency, - totalGrossValue: totalGrossValue ?? this.totalGrossValue, - totalNetValue: totalNetValue ?? this.totalNetValue, - desiredTime: desiredTime ?? this.desiredTime, - filePaths: filePaths ?? this.filePaths, - discount: discount ?? this.discount, - state: state ?? this.state, - specialAgreements: specialAgreements ?? this.specialAgreements, - paymentOptions: paymentOptions ?? this.paymentOptions, - carId: carId ?? this.carId, - notes: notes ?? this.notes, - images: images ?? this.images, - prepayment: prepayment ?? this.prepayment, - paymentAtDelivery: paymentAtDelivery ?? this.paymentAtDelivery, - payment: payment ?? this.payment, - options: options ?? this.options, - ); - } - - Article? findArticleWithNoteId(String noteId) { - if (discount != null && discount?.noteId == noteId) { - return discount?.article; - } - - int index = articles.indexWhere((article) => article.removeNoteId == noteId); - // If no article with an according remove note id is found, skip this step. - if (index == -1) { - return null; - } - - return articles[index]; - } - - double getGrossPrice() { - return articles.fold(0, (acc, article) { - double price = article.getGrossPriceScanned(); - - if (!article.scannable) { - price = article.getGrossPrice(); - } - - return acc + price; - }); - } - - double getOpenPrice() { - return getGrossPrice() - prepayment; - } - - List
getDeliveredArticles() { - return articles - .where((article) { - if (!article.scannable) return true; - if (article.isParent && article.components.isNotEmpty) { - return article.isFullyScanned; - } - return article.scannedAmount > 0; - }) - .toList(); - } - - bool containsArticle(String articleNr) { - return articles.any((article) => article.hasArticleNumber(articleNr)); - } - - Article getArticle(String nr) { - return articles.firstWhere((article) => article.articleNumber == nr); - } - - /// Find the parent article whose BOM contains [componentArticleNr]. - Article? findParentOfComponent(String componentArticleNr) { - for (final article in articles) { - if (article.isParent && - article.findComponent(componentArticleNr) != null) { - return article; - } - } - return null; - } - - List
getScannableArticles() { - return articles.where((article) => article.scannable).toList(); - } - - bool allArticlesScanned() { - return getScannableArticles().every( - (article) => article.isFullyScanned, - ); - } - - void scanArticle(String nr) { - if (!containsArticle(nr)) { - return; - } - - Article article = getArticle(nr); - if (article.scannedAmount < article.amount) { - article.scannedAmount += 1; - } - } - - factory Delivery.fromDTO(DeliveryDTO dto) { - double getPrice() { - return double.parse(dto.totalPrice == "" ? "0" : dto.totalPrice); - } - - return Delivery( - customer: Customer.fromDTO(dto.customer), - id: dto.internalReceiptNo, - articles: dto.articles.map(Article.fromDTO).toList(), - paymentOptions: PaymentOptions(value: getPrice()), - notes: dto.notes.map(Note.fromDto).toList(), - price: getPrice(), - payment: Payment.fromDTO(dto.payment), - specialAgreements: dto.specialAggreements, - desiredTime: dto.desiredTime, - prepayment: double.tryParse(dto.prepayment) ?? 0.0, - discount: - dto.discount == null - ? null - : Discount( - article: Article.fromDTO(dto.discount!.article), - note: dto.discount!.note, - noteId: dto.discount!.noteId, - ), - paymentAtDelivery: double.tryParse(dto.paymentAtDelivery) ?? 0.0, - images: dto.images.map(ImageNote.fromDTO).toList(), - // Legacy: ERPframe-Backend liefert int-Strings; im neuen Backend - // sind das UUID-Strings. Beide werden hier transparent - // weitergereicht. - carId: dto.carId.isEmpty ? null : dto.carId, - totalGrossValue: double.parse( - dto.totalGrossValue == "" ? "0" : dto.totalGrossValue, - ), - totalNetValue: double.parse( - dto.totalNetValue == "" ? "0" : dto.totalNetValue, - ), - filePaths: [], - options: - dto.options.map((option) => DeliveryOption.fromDTO(option)).toList(), - state: - dto.state != "" - ? getDeliveryStateFromString(dto.state) - : DeliveryState.ongoing, - contactPerson: ContactPerson.fromDTO(dto.contactPerson), - currency: dto.currency ?? "EUR", - ); - } -} diff --git a/lib/model/tour.dart b/lib/model/tour.dart deleted file mode 100644 index 4d1fa68..0000000 --- a/lib/model/tour.dart +++ /dev/null @@ -1,133 +0,0 @@ -import 'package:hl_lieferservice/dto/payment.dart'; - -import 'car.dart'; -import 'delivery.dart'; - -class Payment { - const Payment({ - required this.description, - required this.shortcode, - required this.id, - }); - - final String id; - final String description; - final String shortcode; - - factory Payment.fromDTO(PaymentMethodDTO dto) { - return Payment( - description: dto.description, - shortcode: dto.shortCode, - id: dto.id, - ); - } - - Payment copyWith({ - String? description, - String? shortcode, - String? id, - }) { - return Payment( - description: description ?? this.description, - shortcode: shortcode ?? this.shortcode, - id: id ?? this.id, - ); - } -} - -class Tour { - Tour({ - required this.date, - required this.deliveries, - required this.driver, - required this.discountArticleNumber, - required this.paymentMethods, - }) : deliveriesPerCar = {}; - - final DateTime date; - final String discountArticleNumber; - Driver driver; - final List deliveries; - List paymentMethods; - - Map> deliveriesPerCar; - - int getFinishedDeliveries(String carId) { - return deliveries - .where((delivery) => delivery.carId == carId) - .where((delivery) => delivery.state == DeliveryState.finished) - .toList() - .length; - } - - /// Returns true if the car still has loaded articles assigned to a delivery - /// that has not been finished yet. Scannable articles count when their - /// effective scanned amount (scanned minus removed) is positive; non-scannable - /// articles count when their target amount is greater than zero. - bool hasUndeliveredLoadedArticles(String carId) { - return deliveries.any((delivery) { - if (delivery.carId != carId) return false; - if (delivery.state == DeliveryState.finished) return false; - return delivery.articles.any((article) { - if (article.scannable) { - return article.scannedAmount > article.scannedRemovedAmount; - } - return article.amount > 0; - }); - }); - } - - Tour copyWith({ - DateTime? date, - String? discountArticleNumber, - Driver? driver, - List? deliveries, - List? paymentMethods, - }) { - return Tour( - date: date ?? this.date.copyWith(), - discountArticleNumber: - discountArticleNumber ?? this.discountArticleNumber, - driver: driver ?? this.driver, - deliveries: deliveries ?? this.deliveries, - paymentMethods: paymentMethods ?? this.paymentMethods, - ); - } -} - -class Driver { - Driver({ - required this.teamNumber, - required this.name, - required this.salutation, - required this.cars, - }); - - final int teamNumber; - final String name; - final String salutation; - List cars; - - /// If the driver is representing a company, then the company name is returned. - String getSalutatedLastName() { - if (salutation != "Firma") { - return "$salutation, ${name.split(" ").first}"; - } - - return "$salutation, $name"; - } - - Driver copyWith( - int? teamNumber, - String? name, - String? salutation, - List? cars, - ) { - return Driver( - teamNumber: teamNumber ?? this.teamNumber, - name: name ?? this.name, - salutation: salutation ?? this.salutation, - cars: cars ?? this.cars, - ); - } -} diff --git a/lib/model/user.dart b/lib/model/user.dart deleted file mode 100644 index 4cba11f..0000000 --- a/lib/model/user.dart +++ /dev/null @@ -1,5 +0,0 @@ -class User { - const User({required this.id}); - - final String id; -} \ No newline at end of file diff --git a/lib/persistence.dart b/lib/persistence.dart deleted file mode 100644 index 13a536a..0000000 --- a/lib/persistence.dart +++ /dev/null @@ -1,20 +0,0 @@ -import 'package:path_provider/path_provider.dart'; -import 'dart:io'; - -class FileStorage { - Future _path(String file) async { - return "${(await getApplicationDocumentsDirectory()).path}/$file"; - } - - Future write(String name, String content) async { - await File(await _path(name)).writeAsString(content); - } - - Future read(String name) async { - return await File(await _path(name)).readAsString(); - } - - Future exist(String name) async { - return File(await _path(name)).exists(); - } -} \ No newline at end of file diff --git a/lib/repository.dart b/lib/repository.dart deleted file mode 100644 index f64064f..0000000 --- a/lib/repository.dart +++ /dev/null @@ -1,11 +0,0 @@ -import 'package:hl_lieferservice/persistence.dart'; - -abstract class BaseRepository { - final String path; - - const BaseRepository({required this.path}); - - Future exist() { - return FileStorage().exist(path); - } -} \ No newline at end of file diff --git a/lib/repository/config.dart b/lib/repository/config.dart deleted file mode 100644 index 98efd34..0000000 --- a/lib/repository/config.dart +++ /dev/null @@ -1,21 +0,0 @@ -import 'dart:convert'; - -import 'package:hl_lieferservice/services/erpframe.dart'; -import 'package:hl_lieferservice/persistence.dart'; -import 'package:hl_lieferservice/repository.dart'; - -/// This repository manages the configuration file stored on the phone -/// locally. -class ConfigurationRepository extends BaseRepository{ - const ConfigurationRepository({required super.path}); - - Future getDocuFrameConfiguration() async { - String content = await FileStorage().read(path); - return LocalDocuFrameConfiguration.fromJson(json.decode(content)); - } - - Future setDocuFrameConfiguration(LocalDocuFrameConfiguration configuration) async { - String content = json.encode(configuration.toJson()); - await FileStorage().write(path, content); - } -} \ No newline at end of file diff --git a/lib/repository/file.dart b/lib/repository/file.dart deleted file mode 100644 index e37e1ae..0000000 --- a/lib/repository/file.dart +++ /dev/null @@ -1,38 +0,0 @@ -import 'dart:io'; -import 'dart:typed_data'; - -import 'package:image_picker/image_picker.dart'; - -class FileRepository { - const FileRepository({required this.baseDirectory}); - final Directory baseDirectory; - - Future persistTemporaryFile(XFile file, String name) async { - File fileOnDisk = File("${baseDirectory.path}/$name"); - await fileOnDisk.writeAsBytes(await file.readAsBytes()); - - return fileOnDisk; - } - - Future persistTemporaryFileFromBytes(Uint8List bytes, String name) async { - File fileOnDisk = File("${baseDirectory.path}/$name"); - await fileOnDisk.writeAsBytes(bytes as List); - - return fileOnDisk; - } - - Future> getFilesByPrefix(String prefix) async { - return await baseDirectory - .list() - .where((file) => file is File) - .map((file) => file as File) - .where((file) => file.path.split("/").last.startsWith(prefix)) - .toList(); - } - - Future deleteAllFilesByPrefix(String prefix) async { - for (File file in await getFilesByPrefix(prefix)) { - await file.delete(); - } - } -} diff --git a/lib/repository/user_repository.dart b/lib/repository/user_repository.dart deleted file mode 100644 index 67a0560..0000000 --- a/lib/repository/user_repository.dart +++ /dev/null @@ -1,3 +0,0 @@ -class UserRepository { - -} \ No newline at end of file diff --git a/lib/services/erpframe.dart b/lib/services/erpframe.dart deleted file mode 100644 index 36a7ddf..0000000 --- a/lib/services/erpframe.dart +++ /dev/null @@ -1,23 +0,0 @@ -import 'package:hl_lieferservice/util.dart'; - -class LocalDocuFrameConfiguration { - String backendUrl; - - LocalDocuFrameConfiguration({required this.backendUrl}); - - Map toJson() { - return {"backendUrl": backendUrl}; - } - - factory LocalDocuFrameConfiguration.fromJson(Map json) { - return LocalDocuFrameConfiguration( - backendUrl: getValueOrThrowIfNotPresent("backendUrl", json), - ); - } -} - -class ErpFrameService { - ErpFrameService({required this.backendUrl}); - - final String backendUrl; -} diff --git a/lib/util.dart b/lib/util.dart deleted file mode 100644 index fcc1d59..0000000 --- a/lib/util.dart +++ /dev/null @@ -1,86 +0,0 @@ -import 'package:hl_lieferservice/exceptions.dart'; -import 'package:hl_lieferservice/feature/authentication/bloc/auth_state.dart'; -import 'package:hl_lieferservice/feature/authentication/exceptions.dart'; -import 'package:hl_lieferservice/main.dart'; -import 'package:hl_lieferservice/services/erpframe.dart'; -import 'package:intl/intl.dart'; - -import 'model/delivery.dart'; - -dynamic getValueOrThrowIfNotPresent(String key, Map json) { - if (!json.containsKey(key)) { - throw Exception("Wert '$key' in der Konfigurationsdatei nicht gefunden"); - } - - return json[key]; -} - -/// Returns the current date as string in format YYYY-MM-DD. -String getTodayDate() { - return DateFormat('yyyy-MM-dd').format(DateTime.now()); -} - -String concatenateRegexMatches(String input, String pattern) { - final regex = RegExp(pattern); - final matches = regex.allMatches(input); - - return matches.fold("", (acc, match) => acc + match[0]!); -} - -/// Return the value or the default value if the string is empty. -String getOrDefaultValueFromStr(String value, String defaultValue) { - if (value != "") { - return value; - } else { - return defaultValue; - } -} - -DeliveryState getDeliveryStateFromString(String str) { - switch (str) { - case "laufend": - return DeliveryState.ongoing; - case "abgebrochen": - return DeliveryState.canceled; - case "vertagt": - return DeliveryState.onhold; - case "geliefert": - return DeliveryState.finished; - default: - throw Exception("Invalid state"); - } -} - -String getName(DeliveryState state) { - switch (state) { - case DeliveryState.ongoing: - return "laufend"; - case DeliveryState.canceled: - return "abgebrochen"; - case DeliveryState.onhold: - return "unterbrochen"; - case DeliveryState.finished: - return "ausgeliefert"; - } -} - -Map getSessionOrThrow() { - if (locator.isRegistered()) { - return {"Cookie": "session_id=${locator.get().sessionId}"}; - } else { - throw UserUnauthorized(); - } -} - -LocalDocuFrameConfiguration getConfig() { - if (locator.isRegistered()) { - return locator.get(); - } else { - throw AppConfigNotFound(); - } -} - -Uri urlBuilder(String path) { - LocalDocuFrameConfiguration config = getConfig(); - return Uri.parse("${config.backendUrl}/v1/execute/$path"); -} \ No newline at end of file diff --git a/lib/widget/app.dart b/lib/widget/app.dart index 0f69ca4..f30fc7f 100644 --- a/lib/widget/app.dart +++ b/lib/widget/app.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:hl_lieferservice/bloc/app_bloc.dart'; +import 'package:hl_lieferservice/data/cache/attachment_cache.dart'; import 'package:hl_lieferservice/data/network/keycloak_oidc_token_provider.dart'; import 'package:hl_lieferservice/feature/authentication/bloc/auth_bloc.dart'; import 'package:hl_lieferservice/feature/authentication/bloc/auth_event.dart'; @@ -11,18 +12,20 @@ import 'package:hl_lieferservice/feature/car_selection/presentation/car_selectio import 'package:hl_lieferservice/feature/car_selection/repository/car_selection_repository.dart'; import 'package:hl_lieferservice/data/repository/cars_repository_impl.dart'; import 'package:hl_lieferservice/feature/cars/bloc/cars_bloc.dart'; +import 'package:hl_lieferservice/feature/cars/bloc/cars_state.dart'; import 'package:hl_lieferservice/feature/cars/presentation/car_management_page.dart'; +import 'package:hl_lieferservice/data/repository/payment_methods_repository_impl.dart'; +import 'package:hl_lieferservice/feature/payment_methods/bloc/payment_methods_cubit.dart'; import 'package:holzleitner_api/holzleitner_api.dart' show HolzleitnerApi; +import 'package:hl_lieferservice/data/repository/tour_repository_impl.dart'; import 'package:hl_lieferservice/feature/delivery/bloc/phase_bloc.dart'; import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.dart'; import 'package:hl_lieferservice/feature/delivery/bloc/tour_state.dart'; -import 'package:hl_lieferservice/feature/delivery/repository/tour_repository.dart'; import 'package:hl_lieferservice/widget/home/bloc/navigation_bloc.dart'; import 'package:hl_lieferservice/widget/operations/bloc/operation_bloc.dart'; import 'package:hl_lieferservice/widget/operations/presentation/operation_view_enforcer.dart'; import 'package:hl_lieferservice/bloc/app_states.dart'; -import '../feature/delivery/service/tour_service.dart'; import 'home/presentation/home.dart'; class DeliveryApp extends StatefulWidget { @@ -55,14 +58,15 @@ class _DeliveryAppState extends State { ..add(const RestoreSessionRequested()), ), BlocProvider( - create: - (context) => TourBloc( - opBloc: context.read(), - authBloc: context.read(), - tourRepository: TourRepository( - service: TourService(), - ), - ), + // Phase-C+D-2-Migration: produktive TourRepository-Impl + // gegen das generierte Rust-Backend-API. Account-Filter + // serverseitig aus dem JWT, deshalb braucht der Bloc + // keinen AuthBloc-Bezug mehr. + create: (context) => TourBloc( + tourRepository: TourRepositoryImpl(locator()), + opBloc: context.read(), + attachmentCache: locator(), + ), ), BlocProvider( create: (context) => @@ -79,19 +83,41 @@ class _DeliveryAppState extends State { ), ), BlocProvider( - // PhaseBloc darf erst NACH dem TourBloc gebaut werden, - // da er die Anzahl der Team-Fahrzeuge daraus liest, um - // beim ersten Load eines Fahrzeugs die korrekte - // Eintrittsphase (Auswählen vs. Sortieren) zu bestimmen. + // PhaseBloc liest die Team-Fahrzeug-Anzahl jetzt direkt + // aus dem CarsBloc — der ist die alleinige Quelle der + // Fahrzeug-Stammdaten. Beim ersten Load eines Fahrzeugs + // bestimmt das die Eintrittsphase (Auswählen vs. Sortieren). create: (context) => PhaseBloc( carCountResolver: () { + final carsState = context.read().state; + return carsState is CarsLoaded + ? carsState.cars.length + : null; + }, + // Bindet die persistierten Phasen-Häkchen an die aktuelle + // Tour-Version (Tour.syncedAt). Ein erneuter Sync/Seed + // schreibt eine neue syncedAt → neuer Token → frische + // Phasen, ohne dass alte lokale Häkchen hängen bleiben. + tourTokenResolver: () { final tourState = context.read().state; return tourState is TourLoaded - ? tourState.tour.driver.cars.length + ? tourState.details.tour.syncedAt + .millisecondsSinceEpoch + .toString() : null; }, ), ), + BlocProvider( + // Zahlungsmethoden sind firmenweite Stammdaten — wir laden + // sie einmal beim App-Start und cachen sie im Cubit. Der + // Detail-Screen einer Lieferung greift darauf zu, um den + // `paymentMethodId`-FK auf einen lesbaren Namen aufzulösen. + create: (context) => PaymentMethodsCubit( + repository: + PaymentMethodsRepositoryImpl(locator()), + )..load(), + ), ], child: MaterialApp( // Wrap the Navigator (not just the home route) so the loading diff --git a/lib/widget/attachment_image.dart b/lib/widget/attachment_image.dart new file mode 100644 index 0000000..acae1bd --- /dev/null +++ b/lib/widget/attachment_image.dart @@ -0,0 +1,184 @@ +import 'dart:typed_data'; + +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:holzleitner_api/holzleitner_api.dart' show HolzleitnerApi; + +import 'package:hl_lieferservice/data/cache/attachment_cache.dart'; +import 'package:hl_lieferservice/main.dart' show locator; + +/// Lädt und zeigt ein Attachment-Vorschaubild über +/// `GET /attachments/{id}` an. Nutzt die geteilte, authentifizierte +/// Dio-Instanz (`HolzleitnerApi.dio`) — der Auth-Interceptor hängt den +/// Bearer-Token an, ein generierter Client-Methodenaufruf ist für die +/// Binär-Antwort nicht zuverlässig. +/// +/// Reines Lese-Widget (kein Bloc): Bild-Anzeige ist kein App-State. +class AttachmentImage extends StatefulWidget { + const AttachmentImage({ + super.key, + required this.attachmentId, + this.width = 1024, + this.height = 1024, + this.quality = 80, + this.fit = BoxFit.cover, + this.deleted = false, + }); + + /// Unsere Attachment-UUID (steht in `DeliveryNote.imageAttachment`). + final String attachmentId; + + /// Wenn `true`: die lokale Bilddatei wurde nach dem Report-Upload gelöscht + /// (das Bild steckt im Lieferbericht in DOCUframe). Statt eines Downloads + /// wird ein Hinweis gezeigt. + final bool deleted; + + /// Angefragte Vorschau-Abmessungen (Backend rendert per DOCUframe). + final int width; + final int height; + final int quality; + final BoxFit fit; + + @override + State createState() => _AttachmentImageState(); +} + +class _AttachmentImageState extends State { + late Future _future; + + @override + void initState() { + super.initState(); + // Gelöschte Anhänge nicht laden — es gibt keine lokale Datei mehr. + _future = widget.deleted ? Future.value(Uint8List(0)) : _load(); + } + + @override + void didUpdateWidget(AttachmentImage oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.attachmentId != widget.attachmentId || + oldWidget.width != widget.width || + oldWidget.height != widget.height || + oldWidget.quality != widget.quality) { + _future = _load(); + } + } + + static const _ext = 'jpeg'; + + Future _load() async { + final cache = locator(); + + // 1. Disk-Cache zuerst — Attachments sind unveränderlich, ein Treffer + // ist also immer gültig (auch offline). + final cached = await cache.read( + attachmentId: widget.attachmentId, + w: widget.width, + h: widget.height, + q: widget.quality, + ext: _ext, + ); + if (cached != null) return cached; + + // 2. Miss → über die authentifizierte Dio-Instanz aus DOCUframe holen. + final api = locator(); + final response = await api.dio.get>( + '/attachments/${widget.attachmentId}', + queryParameters: { + 'w': widget.width, + 'h': widget.height, + 'q': widget.quality, + 'ext': _ext, + }, + options: Options(responseType: ResponseType.bytes), + ); + final bytes = Uint8List.fromList(response.data ?? const []); + + // 3. Erfolgreichen Download persistieren (best-effort, blockiert die + // Anzeige nicht — write schluckt Fehler). + if (bytes.isNotEmpty) { + await cache.write( + attachmentId: widget.attachmentId, + w: widget.width, + h: widget.height, + q: widget.quality, + ext: _ext, + bytes: bytes, + ); + } + return bytes; + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + if (widget.deleted) { + return _DeletedHint(fit: widget.fit); + } + return FutureBuilder( + future: _future, + builder: (context, snapshot) { + if (snapshot.connectionState != ConnectionState.done) { + return const Center( + child: Padding( + padding: EdgeInsets.all(16), + child: SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + ); + } + if (snapshot.hasError || + snapshot.data == null || + snapshot.data!.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.all(16), + child: Icon( + Icons.broken_image_outlined, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ); + } + return Image.memory(snapshot.data!, fit: widget.fit); + }, + ); + } +} + +/// Hinweis für gelöschte Bild-Anhänge: das Bild liegt im Lieferbericht. +class _DeletedHint extends StatelessWidget { + const _DeletedHint({required this.fit}); + + final BoxFit fit; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Container( + color: theme.colorScheme.surfaceContainerHighest, + alignment: Alignment.center, + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.picture_as_pdf_outlined, + color: theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 8), + Text( + 'Bild im Lieferbericht enthalten', + textAlign: TextAlign.center, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ); + } +} diff --git a/lib/widget/home/presentation/home.dart b/lib/widget/home/presentation/home.dart index 9f2a038..d6cd688 100644 --- a/lib/widget/home/presentation/home.dart +++ b/lib/widget/home/presentation/home.dart @@ -1,11 +1,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:hl_lieferservice/feature/authentication/bloc/auth_bloc.dart'; -import 'package:hl_lieferservice/feature/authentication/bloc/auth_state.dart'; import 'package:hl_lieferservice/feature/car_selection/bloc/bloc.dart'; import 'package:hl_lieferservice/feature/car_selection/bloc/state.dart'; -import 'package:hl_lieferservice/feature/car_selection/presentation/selected_car_bar.dart'; -import 'package:hl_lieferservice/feature/cars/presentation/car_management_page.dart'; +import 'package:hl_lieferservice/feature/cars/bloc/cars_bloc.dart'; +import 'package:hl_lieferservice/feature/cars/bloc/cars_event.dart'; +import 'package:hl_lieferservice/feature/cars/bloc/cars_state.dart'; import 'package:hl_lieferservice/feature/delivery/bloc/phase_bloc.dart'; import 'package:hl_lieferservice/feature/delivery/bloc/phase_event.dart'; import 'package:hl_lieferservice/feature/delivery/bloc/phase_state.dart'; @@ -17,10 +16,6 @@ import 'package:hl_lieferservice/feature/delivery/overview/presentation/delivery import 'package:hl_lieferservice/feature/delivery/overview/presentation/delivery_selection_page.dart'; import 'package:hl_lieferservice/feature/delivery/overview/presentation/delivery_sort_page.dart'; import 'package:hl_lieferservice/feature/loading/presentation/loading_overview_page.dart'; -import 'package:hl_lieferservice/feature/settings/presentation/settings_page.dart'; -import 'package:hl_lieferservice/widget/home/bloc/navigation_bloc.dart'; -import 'package:hl_lieferservice/widget/home/bloc/navigation_state.dart'; -import 'package:hl_lieferservice/widget/navigation_bar/presentation/navigation_bar.dart'; /// Wurzel-Widget des authentifizierten Bereichs. Routet anhand der aktuellen /// Phase des ausgewählten Fahrzeugs: @@ -40,28 +35,75 @@ class Home extends StatefulWidget { class _HomeState extends State { String? _initializedCarId; + /// Merkt, für welches Auto die automatische Lieferungs-Zuordnung bereits + /// erledigt ist (Ein-Auto-Teams, s. [_ensureSingleCarAssignment]). + String? _autoAssignedForCar; + @override void initState() { super.initState(); - // Tour beim ersten Aufbau laden. - final authState = context.read().state as Authenticated; - context.read().add(LoadTour(teamId: authState.user.number)); + // Tour beim ersten Aufbau laden. Account-Filter sitzt jetzt + // serverseitig im JWT — kein Personalnummer-Argument mehr nötig. + context.read().add(const LoadTour()); + + // CarsBloc auch hier triggern: wenn der CarSelectBloc beim App-Start + // eine valide Tages-Auswahl aus den SharedPreferences fand, wurde die + // CarSelectionPage übersprungen — und damit auch ihr `CarLoad`-Trigger. + // Ohne diesen Aufruf hängen drei Stellen am leeren CarsBloc-State: + // `PhaseStepper.carCount`, `app.carCountResolver` (Eintrittsphase) und + // `delivery_selection_page._plateFor` (Vergeben-Tab zeigt sonst "?"). + // Der Bloc-interne `if (state is CarsLoaded && !event.force) return;` + // macht den Aufruf idempotent. + context.read().add(const CarLoad()); } /// Stellt sicher, dass für das aktuell gewählte Auto eine Phase im /// [PhaseBloc] existiert. Wird im build() reaktiv aufgerufen, daher mit /// `_initializedCarId` gegen mehrfache Loads gesichert. /// - /// Wichtig: Wir feuern den Load erst, sobald die Tour geladen ist — - /// sonst kennt der PhaseBloc die Anzahl der Team-Fahrzeuge nicht und - /// würde fälschlich mit `sortieren` einsteigen, statt mit `auswaehlen`. + /// Wichtig: Wir feuern den Load erst, sobald sowohl Tour als auch Cars + /// einen geladen-Zustand haben — sonst fragt der `PhaseBloc` den + /// `carCountResolver` ab, bekommt `null` und entscheidet für Mehr-Auto- + /// Teams fälschlich auf `sortieren` statt `auswaehlen`. void _ensurePhaseLoaded(String carId) { if (_initializedCarId == carId) return; + final carsState = context.read().state; + if (carsState is! CarsLoaded) return; _initializedCarId = carId; context.read().add(PhaseLoadForCar(carId: carId)); } + /// Ein-Auto-Teams überspringen die „Auswählen"-Phase (s. [PhaseBloc]), + /// in der Lieferungen normalerweise einem Fahrzeug zugeordnet werden. + /// Ohne Zuordnung (`assignedCarId`) blieben sie in „Ausliefern" unsichtbar. + /// Daher hier einmalig: ist genau EIN Fahrzeug im Team, werden alle noch + /// nicht zugeordneten Lieferungen automatisch diesem Fahrzeug zugewiesen. + /// + /// Reaktiv (im build) aufgerufen, aber per [_autoAssignedForCar] gegen + /// Mehrfachausführung gesichert. Greift erst, wenn Tour UND Cars geladen + /// sind (sonst Abbruch ohne Flag → erneuter Versuch beim nächsten Build). + void _ensureSingleCarAssignment(String carId) { + if (_autoAssignedForCar == carId) return; + final carsState = context.read().state; + final tourState = context.read().state; + if (carsState is! CarsLoaded || tourState is! TourLoaded) return; + + _autoAssignedForCar = carId; + // Nur Ein-Auto-Teams: bei ≥2 Fahrzeugen entscheidet der Fahrer aktiv in + // der „Auswählen"-Phase, hier wird bewusst nichts automatisch zugewiesen. + if (carsState.cars.length != 1) return; + + final tourBloc = context.read(); + for (final delivery in tourState.details.deliveries) { + if (delivery.assignedCarId != carId) { + tourBloc.add( + AssignCarToDelivery(deliveryId: delivery.id, carId: carId), + ); + } + } + } + @override Widget build(BuildContext context) { return BlocBuilder( @@ -74,32 +116,49 @@ class _HomeState extends State { final carId = carState.selectedCar.id.toString(); - return BlocBuilder( - // Tour-Status mitnehmen, weil die Eintrittsphase davon abhängt. - // Nur bei TourLoaded triggern wir den Phasen-Load. - builder: (context, tourState) { - if (tourState is TourLoaded) { - _ensurePhaseLoaded(carId); - } - - return BlocBuilder( - builder: (context, phaseState) { - final phase = phaseState is PhaseReady - ? phaseState.phaseFor(carId) - : null; - - // Solange weder Tour noch Phase geladen sind, kurzen Spinner - // zeigen — das dauert in der Praxis maximal einen Frame. - if (phase == null) { - return const Scaffold( - body: Center(child: CircularProgressIndicator()), - ); - } - - return _buildForPhase(context, phase, carState.selectedCar.id); - }, - ); + // Nachzieh-Trigger: wenn Cars erst NACH dem Tour-Build fertig + // werden, würde `_ensurePhaseLoaded` beim Tour-Build noch + // skippen. Dieser Listener feuert sobald `CarsLoaded` da ist. + return BlocListener( + listenWhen: (prev, curr) => + prev is! CarsLoaded && curr is CarsLoaded, + listener: (context, _) { + _ensurePhaseLoaded(carId); + _ensureSingleCarAssignment(carId); }, + child: BlocBuilder( + builder: (context, tourState) { + if (tourState is TourLoaded || tourState is TourEmpty) { + _ensurePhaseLoaded(carId); + } + if (tourState is TourLoaded) { + _ensureSingleCarAssignment(carId); + } + + return BlocBuilder( + builder: (context, phaseState) { + final phase = phaseState is PhaseReady + ? phaseState.phaseFor(carId) + : null; + + // Solange Tour, Cars oder Phase noch laden, kurzen + // Spinner zeigen — das dauert in der Praxis maximal + // ein paar Frames. + if (phase == null) { + return const Scaffold( + body: Center(child: CircularProgressIndicator()), + ); + } + + return _buildForPhase( + context, + phase, + carState.selectedCar.id, + ); + }, + ); + }, + ), ); }, ); @@ -129,46 +188,15 @@ class _HomeState extends State { } } -/// Klassisches Home für die Auslieferungs-Phase: BottomNavigationBar mit -/// drei Tabs (Auslieferung / Fahrzeuge / Einstellungen). Die Beladung als -/// Tab entfällt bewusst — wer in dieser Phase zurück zur Beladung möchte, -/// nutzt den Phasen-Stepper auf den jeweiligen Pages oder den Drawer. +/// Home für die Auslieferungs-Phase. Reine Hülle um die Übersicht — es gibt +/// keine BottomNavigationBar mehr: Fahrzeug-Verwaltung und Einstellungen +/// sind über den Drawer erreichbar, der Fahrzeug-Wechsel direkt aus dem +/// PhaseStepper (Icon neben dem Plate). class _DeliveryHome extends StatelessWidget { const _DeliveryHome(); - Widget _buildPage(int index) { - switch (index) { - case 0: - return const DeliveryOverviewPage(); - case 1: - return const CarManagementPage(); - case 2: - return const SettingsPage(); - default: - return const SizedBox.shrink(); - } - } - @override Widget build(BuildContext context) { - return BlocBuilder( - builder: (context, state) { - final navIndex = state is NavigationInfo ? state.navigationIndex : 0; - // Bei einem Tab-Index, der außerhalb des neuen Bereichs liegt - // (z. B. vom alten 4-Tab-Layout: 0..3), normieren wir defensiv auf 0. - final safeIndex = (navIndex >= 0 && navIndex <= 2) ? navIndex : 0; - - return Scaffold( - body: _buildPage(safeIndex), - bottomNavigationBar: Column( - mainAxisSize: MainAxisSize.min, - children: const [ - SelectedCarBar(), - AppNavigationBar(), - ], - ), - ); - }, - ); + return const DeliveryOverviewPage(); } } diff --git a/lib/widget/operations/bloc/operation_bloc.dart b/lib/widget/operations/bloc/operation_bloc.dart index ba4434d..64b95b9 100644 --- a/lib/widget/operations/bloc/operation_bloc.dart +++ b/lib/widget/operations/bloc/operation_bloc.dart @@ -15,7 +15,7 @@ class OperationBloc extends Bloc { /// Minimum time the overlay stays visible, even if the underlying request /// completes faster. Prevents a "did anything happen?" UX where a sub-100 ms /// roundtrip flashes the overlay for one frame. - static const Duration _minimumDisplayDuration = Duration(milliseconds: 350); + static const Duration _minimumDisplayDuration = Duration(milliseconds: 500); OperationBloc() : super(OperationIdle()) { on(_startOperation); @@ -40,6 +40,13 @@ class OperationBloc extends Bloc { ) async { _inFlightCount = (_inFlightCount - 1).clamp(0, 1 << 30); + // Spinner-Mindestdauer einhalten, BEVOR wir ihn schließen — auch wenn eine + // Erfolgsmeldung folgt. Sonst blitzt der Spinner bei schnellen Requests nur + // einen Frame lang auf (genau der Grund für den vorherigen „kein Spinner"). + if (_inFlightCount == 0) { + await _awaitMinimumOverlayDuration(); + } + if (event.message != null) { emit(OperationFinished(message: event.message)); await Future.delayed(const Duration(seconds: 5)); @@ -48,7 +55,6 @@ class OperationBloc extends Bloc { if (_inFlightCount > 0) { emit(OperationInProgress()); } else { - await _awaitMinimumOverlayDuration(); _overlayStartedAt = null; emit(OperationIdle()); } @@ -59,6 +65,13 @@ class OperationBloc extends Bloc { Emitter emit, ) async { _inFlightCount = (_inFlightCount - 1).clamp(0, 1 << 30); + + // Auch im Fehlerfall den Spinner mindestens kurz zeigen, bevor die + // Fehler-SnackBar erscheint. + if (_inFlightCount == 0) { + await _awaitMinimumOverlayDuration(); + } + emit(OperationFailed(message: event.message)); await Future.delayed(const Duration(seconds: 5)); diff --git a/lib/widget/operations/presentation/operation_view_enforcer.dart b/lib/widget/operations/presentation/operation_view_enforcer.dart index bda7abe..d3ab5bc 100644 --- a/lib/widget/operations/presentation/operation_view_enforcer.dart +++ b/lib/widget/operations/presentation/operation_view_enforcer.dart @@ -48,18 +48,30 @@ class OperationViewEnforcer extends StatelessWidget { color: Colors.black54, ), Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const CircularProgressIndicator(), - if (progressMessage != null) ...[ - const SizedBox(height: 16), - Text( - progressMessage, - style: const TextStyle(color: Colors.white), - ), + // Material liefert einen DefaultTextStyle — sonst rendert + // der Text hier (über dem Navigator, ohne Scaffold) mit + // der gelb-unterstrichenen Fallback-Darstellung. + child: Material( + type: MaterialType.transparency, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircularProgressIndicator(), + if (progressMessage != null) ...[ + const SizedBox(height: 16), + Text( + progressMessage, + textAlign: TextAlign.center, + style: const TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.w500, + decoration: TextDecoration.none, + ), + ), + ], ], - ], + ), ), ), ], diff --git a/lib/widget/phase_stepper/phase_stepper.dart b/lib/widget/phase_stepper/phase_stepper.dart index 7dcc355..15d9407 100644 --- a/lib/widget/phase_stepper/phase_stepper.dart +++ b/lib/widget/phase_stepper/phase_stepper.dart @@ -1,12 +1,13 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:hl_lieferservice/feature/car_selection/bloc/bloc.dart'; +import 'package:hl_lieferservice/feature/car_selection/bloc/events.dart'; import 'package:hl_lieferservice/feature/car_selection/bloc/state.dart'; +import 'package:hl_lieferservice/feature/cars/bloc/cars_bloc.dart'; +import 'package:hl_lieferservice/feature/cars/bloc/cars_state.dart'; import 'package:hl_lieferservice/feature/delivery/bloc/phase_bloc.dart'; import 'package:hl_lieferservice/feature/delivery/bloc/phase_event.dart'; import 'package:hl_lieferservice/feature/delivery/bloc/phase_state.dart'; -import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.dart'; -import 'package:hl_lieferservice/feature/delivery/bloc/tour_state.dart'; import 'package:hl_lieferservice/feature/delivery/model/delivery_phase.dart'; /// Horizontaler Phasen-Stepper für die drei bzw. vier Schritte @@ -19,9 +20,9 @@ import 'package:hl_lieferservice/feature/delivery/model/delivery_phase.dart'; /// * Ein-Auto-Teams: Sortieren, Beladen, Ausliefern (3 Schritte). /// * Mehr-Auto-Teams: Auswählen, Sortieren, Beladen, Ausliefern (4 Schritte). /// -/// Die Sichtbarkeitsliste wird intern aus dem [TourBloc] abgeleitet -/// (Anzahl `tour.driver.cars`). So müssen Aufrufer den Stepper nicht mit -/// Routing-Wissen versorgen — er bleibt eine reine Anzeige-Komponente, +/// Die Sichtbarkeitsliste wird intern aus dem [CarsBloc] abgeleitet +/// (Anzahl Fahrzeuge im Account). So müssen Aufrufer den Stepper nicht +/// mit Routing-Wissen versorgen — er bleibt eine reine Anzeige-Komponente, /// die auf den globalen State reagiert. /// /// Verhalten: @@ -47,8 +48,8 @@ class PhaseStepper extends StatelessWidget { /// Auto-ID, an der die Phase im [PhaseBloc] gespeichert wird. final String carId; - /// Optionaler Override für Tests / Sonderfälle. Default: aus TourBloc - /// abgeleitet (Anzahl cars im Team). + /// Optionaler Override für Tests / Sonderfälle. Default: aus CarsBloc + /// abgeleitet (Anzahl cars im Account). final List? visiblePhases; IconData _iconFor(DeliveryPhase phase) { @@ -107,18 +108,16 @@ class PhaseStepper extends StatelessWidget { final theme = Theme.of(context); final onPrimary = theme.colorScheme.onPrimary; - return BlocBuilder( + return BlocBuilder( // Stepper reagiert auf cars.length-Änderungen — sonst praktisch statisch. buildWhen: (prev, curr) { if (visiblePhases != null) return prev != curr; // override aktiv - final prevCars = prev is TourLoaded ? prev.tour.driver.cars.length : 0; - final currCars = curr is TourLoaded ? curr.tour.driver.cars.length : 0; + final prevCars = prev is CarsLoaded ? prev.cars.length : 0; + final currCars = curr is CarsLoaded ? curr.cars.length : 0; return prevCars != currCars || prev.runtimeType != curr.runtimeType; }, - builder: (context, tourState) { - final carCount = tourState is TourLoaded - ? tourState.tour.driver.cars.length - : 0; + builder: (context, carsState) { + final carCount = carsState is CarsLoaded ? carsState.cars.length : 0; final phases = visiblePhases ?? _effectivePhases(carCount); // Höchste erreichte Phase aus dem PhaseBloc — bestimmt, welche @@ -151,27 +150,9 @@ class PhaseStepper extends StatelessWidget { if (state is! CarSelectComplete) { return const SizedBox.shrink(); } - return Padding( - padding: const EdgeInsets.only(right: 4), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.local_shipping, - color: onPrimary, - size: 18, - ), - const SizedBox(width: 6), - Text( - state.selectedCar.plate, - style: TextStyle( - color: onPrimary, - fontWeight: FontWeight.bold, - fontSize: 14, - ), - ), - ], - ), + return _SelectedCarPill( + plate: state.selectedCar.plate, + onPrimary: onPrimary, ); }, ), @@ -337,6 +318,65 @@ class _StepperItem extends StatelessWidget { } } +/// Dezente Pille rechts oben im Header: Lkw-Icon, Plate, kleiner Swap-Button. +/// +/// Visuell zurückhaltend gehalten: leicht durchscheinender Hintergrund auf +/// dem Primary-Header, kompakter IconButton — der Plate-Text bleibt +/// dominantes Element, der Wechseln-Knopf ist eine sekundäre Geste, die +/// erst auf Anfrage benutzt wird. +class _SelectedCarPill extends StatelessWidget { + const _SelectedCarPill({required this.plate, required this.onPrimary}); + + final String plate; + final Color onPrimary; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.only(left: 10, right: 2), + decoration: BoxDecoration( + color: onPrimary.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.local_shipping, color: onPrimary, size: 16), + const SizedBox(width: 6), + Text( + plate, + style: TextStyle( + color: onPrimary, + fontWeight: FontWeight.bold, + fontSize: 14, + ), + ), + const SizedBox(width: 2), + IconButton( + tooltip: 'Fahrzeug wechseln', + icon: Icon( + Icons.swap_horiz_rounded, + // Etwas heller als das Plate, damit der Button als sekundäre + // Aktion gelesen wird und das Plate nicht überstrahlt. + color: onPrimary.withValues(alpha: 0.85), + size: 18, + ), + visualDensity: VisualDensity.compact, + padding: const EdgeInsets.all(4), + constraints: const BoxConstraints( + minWidth: 32, + minHeight: 32, + ), + splashRadius: 18, + onPressed: () => + context.read().add(CarSelectChange()), + ), + ], + ), + ); + } +} + class _Connector extends StatelessWidget { const _Connector({required this.isPassed}); diff --git a/lib/widget/scanner/article_scanner_stripe.dart b/lib/widget/scanner/article_scanner_stripe.dart new file mode 100644 index 0000000..6cb990c --- /dev/null +++ b/lib/widget/scanner/article_scanner_stripe.dart @@ -0,0 +1,201 @@ +import 'package:flutter/material.dart'; +import 'package:mobile_scanner/mobile_scanner.dart'; + +/// Schmaler Kamera-Stripe mit Torch- und Manual-Entry-Button sowie +/// Zoom-Steuerung. Geteilt zwischen Beladen-Phase und Filial-Abholung. +/// +/// Wichtig: sollte **einmal** und außerhalb eines `PageView` gemountet +/// werden, damit der Kamera-Stream beim Wischen nicht abreißt (eine einzige +/// Kamera-Instanz pro Page-Lebenszyklus). +class ArticleScannerStripe extends StatefulWidget { + const ArticleScannerStripe({ + super.key, + required this.onBarcode, + required this.onManualEntry, + }); + + /// Roh-Wert eines erkannten Barcodes (bereits getrimmt, dedupliziert). + final void Function(String code) onBarcode; + + /// Tap auf das Tastatur-Icon → Aufrufer öffnet den Manual-Entry-Dialog. + final VoidCallback onManualEntry; + + @override + State createState() => _ArticleScannerStripeState(); +} + +class _ArticleScannerStripeState extends State { + late final MobileScannerController _scanner = MobileScannerController( + detectionSpeed: DetectionSpeed.normal, + formats: const [ + BarcodeFormat.ean13, + BarcodeFormat.ean8, + BarcodeFormat.code128, + BarcodeFormat.code39, + BarcodeFormat.qrCode, + ], + ); + + /// Dedupliziert Detections derselben Tastendruck-Geste: der Scanner liest + /// dasselbe Code-Bild viele Frames lang. Nur wenn der Code für + /// `_minRepeatGap` lang nicht gesehen wurde, lassen wir ihn erneut zu. + String? _lastCode; + DateTime _lastEmit = DateTime.fromMillisecondsSinceEpoch(0); + static const Duration _minRepeatGap = Duration(milliseconds: 1200); + + /// Schrittweite der `-` / `+`-Buttons im normalisierten Zoom-Bereich + /// [0.0, 1.0]. 5% pro Tastendruck — fühlt sich auf einem 6"-Display + /// natürlich an, ohne dass der Fahrer zehnmal tappen muss. + static const double _zoomStep = 0.05; + + @override + void dispose() { + _scanner.dispose(); + super.dispose(); + } + + void _onDetect(BarcodeCapture capture) { + if (capture.barcodes.isEmpty) return; + final raw = capture.barcodes.first.rawValue?.trim(); + if (raw == null || raw.isEmpty) return; + final now = DateTime.now(); + if (raw == _lastCode && now.difference(_lastEmit) < _minRepeatGap) { + return; + } + _lastCode = raw; + _lastEmit = now; + widget.onBarcode(raw); + } + + Future _setZoom(double normalized) async { + final clamped = normalized.clamp(0.0, 1.0); + await _scanner.setZoomScale(clamped); + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + SizedBox( + height: 140, + child: Stack( + children: [ + Positioned.fill( + child: MobileScanner( + controller: _scanner, + onDetect: _onDetect, + errorBuilder: (context, error) { + return Container( + color: Colors.black, + child: Center( + child: Padding( + padding: const EdgeInsets.all(12), + child: Text( + 'Kamera nicht verfügbar: ${error.errorCode.name}', + style: const TextStyle(color: Colors.white), + textAlign: TextAlign.center, + ), + ), + ), + ); + }, + ), + ), + Positioned( + right: 8, + top: 8, + child: Material( + color: Colors.black54, + borderRadius: BorderRadius.circular(20), + child: IconButton( + tooltip: 'Manuelle Eingabe', + icon: const Icon(Icons.keyboard, color: Colors.white), + onPressed: widget.onManualEntry, + ), + ), + ), + Positioned( + left: 8, + top: 8, + child: Material( + color: Colors.black54, + borderRadius: BorderRadius.circular(20), + child: IconButton( + tooltip: 'Taschenlampe', + icon: const Icon(Icons.flash_on, color: Colors.white), + onPressed: () => _scanner.toggleTorch(), + ), + ), + ), + ], + ), + ), + _ZoomBar(controller: _scanner, onChange: _setZoom, step: _zoomStep), + ], + ); + } +} + +/// Zoom-Steuerung: `-` Button links, Slider in der Mitte, `+` rechts. +/// Bindet an `MobileScannerController.value.zoomScale` via +/// ValueListenableBuilder — damit reagiert der Slider sofort, wenn das +/// Plugin den Zoom selbst clampt (z. B. bei Geräten, die nicht den vollen +/// Bereich unterstützen). +class _ZoomBar extends StatelessWidget { + const _ZoomBar({ + required this.controller, + required this.onChange, + required this.step, + }); + + final MobileScannerController controller; + final Future Function(double normalized) onChange; + final double step; + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: controller, + builder: (context, state, _) { + final zoom = state.zoomScale; + return Material( + color: Colors.black87, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + child: Row( + children: [ + IconButton( + tooltip: 'Auszoomen', + icon: const Icon(Icons.remove, color: Colors.white), + onPressed: zoom <= 0.0 ? null : () => onChange(zoom - step), + ), + Expanded( + child: SliderTheme( + data: SliderTheme.of(context).copyWith( + activeTrackColor: Colors.white, + inactiveTrackColor: Colors.white24, + thumbColor: Colors.white, + overlayColor: Colors.white24, + trackHeight: 3, + ), + child: Slider( + min: 0.0, + max: 1.0, + value: zoom.clamp(0.0, 1.0), + onChanged: (v) => onChange(v), + ), + ), + ), + IconButton( + tooltip: 'Reinzoomen', + icon: const Icon(Icons.add, color: Colors.white), + onPressed: zoom >= 1.0 ? null : () => onChange(zoom + step), + ), + ], + ), + ), + ); + }, + ); + } +} diff --git a/lib/widget/scanner/item_matcher.dart b/lib/widget/scanner/item_matcher.dart new file mode 100644 index 0000000..db50e06 --- /dev/null +++ b/lib/widget/scanner/item_matcher.dart @@ -0,0 +1,99 @@ +import 'package:hl_lieferservice/domain/entity/delivery.dart'; +import 'package:hl_lieferservice/domain/entity/delivery_item.dart'; +import 'package:hl_lieferservice/domain/entity/tour_details.dart'; + +/// Ergebnis der Item-Auflösung beim Scan. Sealed, damit das UI per +/// `switch` exhaustiv pro Fall eine passende Meldung zeigt. +sealed class ItemMatch { + const ItemMatch(); + const factory ItemMatch.ok(DeliveryItem item) = ItemMatchOk; + const factory ItemMatch.notInDelivery() = ItemMatchNotInDelivery; + const factory ItemMatch.notScannable() = ItemMatchNotScannable; + const factory ItemMatch.allDone() = ItemMatchAllDone; + const factory ItemMatch.allRemoved() = ItemMatchAllRemoved; + const factory ItemMatch.notOpen() = ItemMatchNotOpen; +} + +class ItemMatchOk extends ItemMatch { + const ItemMatchOk(this.item); + final DeliveryItem item; +} + +class ItemMatchNotInDelivery extends ItemMatch { + const ItemMatchNotInDelivery(); +} + +class ItemMatchNotScannable extends ItemMatch { + const ItemMatchNotScannable(); +} + +class ItemMatchAllDone extends ItemMatch { + const ItemMatchAllDone(); +} + +class ItemMatchAllRemoved extends ItemMatch { + const ItemMatchAllRemoved(); +} + +class ItemMatchNotOpen extends ItemMatch { + const ItemMatchNotOpen(); +} + +/// Findet in der Lieferung das erste **nicht fertig** gescannte Item mit der +/// gegebenen Article-Nummer. „Top-down"-Strategie: hat eine Lieferung zwei +/// Belegzeilen mit demselben Artikel (z. B. 20 + 10), wird zuerst die +/// niedrigere Belegzeile gefüllt. Erst wenn diese fertig ist, „rollt" der +/// nächste Scan auf die zweite Zeile weiter. +/// +/// [itemFilter] schränkt die betrachteten Positionen ein — z. B. nur +/// Filial-Items in der Abhol-Phase. Ohne Filter werden alle Positionen +/// berücksichtigt (Beladen-Phase). +/// +/// Klassifiziert den Misserfolgs-Grund (nicht scanbar / bereits fertig / +/// entfernt …), damit das UI dem Fahrer eine sinnvolle Meldung zeigt. +ItemMatch matchItem({ + required Delivery delivery, + required TourDetails details, + required String articleNumber, + bool Function(DeliveryItem item)? itemFilter, +}) { + final normalized = articleNumber.trim(); + final candidates = delivery.items + .where((it) => itemFilter?.call(it) ?? true) + .toList() + ..sort((a, b) => a.belegzeilenNr.compareTo(b.belegzeilenNr)); + final matchingArticle = candidates.where((it) { + final art = details.articleOf(it.articleId); + return art?.articleNumber == normalized; + }).toList(); + + if (matchingArticle.isEmpty) { + return const ItemMatch.notInDelivery(); + } + + // Erstes Item, das wir tatsächlich scannen können. + for (final item in matchingArticle) { + if (item.isDone || item.isRemoved) continue; + final article = details.articleOf(item.articleId); + if (article == null || !article.scannable) continue; + return ItemMatch.ok(item); + } + + // Kein scannbares offenes Item — Grund anhand der Item-Verteilung + // klassifizieren, damit das UI eine sinnvolle Meldung zeigt. + final allNotScannable = matchingArticle.every((it) { + final art = details.articleOf(it.articleId); + return art == null || !art.scannable; + }); + if (allNotScannable) return const ItemMatch.notScannable(); + + final allRemoved = matchingArticle.every((it) => it.isRemoved); + if (allRemoved) return const ItemMatch.allRemoved(); + + final allDone = matchingArticle.every((it) => it.isDone); + if (allDone) return const ItemMatch.allDone(); + + // Gemischte Konstellation (z. B. eine Zeile entfernt, eine fertig) — + // praktisch selten; konservativ als „nicht (mehr) offen" melden. + return const ItemMatch.notOpen(); +} diff --git a/lib/widget/scanner/manual_entry_dialog.dart b/lib/widget/scanner/manual_entry_dialog.dart new file mode 100644 index 0000000..9cf26dd --- /dev/null +++ b/lib/widget/scanner/manual_entry_dialog.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; + +/// Öffnet den manuellen Scan-Code-Eingabe-Dialog und liefert den +/// eingegebenen Code (oder `null` bei Abbruch / leerer Eingabe). +Future showManualEntryDialog(BuildContext context) { + return showDialog( + context: context, + builder: (_) => const ManualEntryDialog(), + ); +} + +/// Fallback-Eingabe, wenn ein Sticker nicht scanbar ist (beschädigt, +/// schlechtes Licht). Erwartet das gleiche Format wie der QR-Code: +/// `Artikelnr;Kundennr;Belegnr`. +class ManualEntryDialog extends StatefulWidget { + const ManualEntryDialog({super.key}); + + @override + State createState() => _ManualEntryDialogState(); +} + +class _ManualEntryDialogState extends State { + final _controller = TextEditingController(); + final _focusNode = FocusNode(); + + @override + void initState() { + super.initState(); + WidgetsBinding.instance + .addPostFrameCallback((_) => _focusNode.requestFocus()); + } + + @override + void dispose() { + _controller.dispose(); + _focusNode.dispose(); + super.dispose(); + } + + void _submit() { + final v = _controller.text.trim(); + if (v.isEmpty) return; + Navigator.of(context).pop(v); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Scan-Code eingeben'), + content: TextField( + controller: _controller, + focusNode: _focusNode, + autocorrect: false, + textInputAction: TextInputAction.done, + decoration: const InputDecoration( + labelText: 'Artikelnr;Kundennr;Belegnr', + hintText: 'z. B. BRETT-200;4711;AB-2026-0001', + helperText: 'Format wie auf dem Sticker — drei Werte mit Semikolon', + ), + onSubmitted: (_) => _submit(), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Abbrechen'), + ), + FilledButton( + onPressed: _submit, + child: const Text('Übernehmen'), + ), + ], + ); + } +} diff --git a/lib/widget/scanner/scan_code_parser.dart b/lib/widget/scanner/scan_code_parser.dart new file mode 100644 index 0000000..2abc496 --- /dev/null +++ b/lib/widget/scanner/scan_code_parser.dart @@ -0,0 +1,28 @@ +/// Geparster Scan-Code im Format +/// `;;`. +typedef ScanCode = ({String articleNumber, int customerErpId, String beleg}); + +/// Parst das QR-Code-Format `;;`. +/// +/// Trimmt jedes Feld, lehnt leere Felder und nicht-numerische +/// Kundennummern ab. Liefert `null`, wenn das Format nicht stimmt — der +/// Aufrufer übersetzt das in eine einheitliche „nicht vorgesehen"-Meldung, +/// damit der Fahrer kein Backstage-Tech-Feedback bekommt. +/// +/// Geteilt zwischen Beladen-Phase (`LoadingCustomerPage`) und Filial-Abholung +/// (`FilialePickupScanPage`) — beide nutzen dasselbe Sticker-Format. +ScanCode? parseScanCode(String raw) { + final parts = raw.split(';'); + if (parts.length != 3) return null; + final articleNumber = parts[0].trim(); + final customerStr = parts[1].trim(); + final beleg = parts[2].trim(); + if (articleNumber.isEmpty || beleg.isEmpty) return null; + final customerErpId = int.tryParse(customerStr); + if (customerErpId == null) return null; + return ( + articleNumber: articleNumber, + customerErpId: customerErpId, + beleg: beleg, + ); +} diff --git a/lib/widget/warehouse_badge.dart b/lib/widget/warehouse_badge.dart index a757e4d..702c405 100644 --- a/lib/widget/warehouse_badge.dart +++ b/lib/widget/warehouse_badge.dart @@ -1,13 +1,13 @@ import 'package:flutter/material.dart'; -/// Einheitliche Visualisierung für "Artikel aus Außenlager". +/// Einheitliche Visualisierung für "Artikel aus Filiale". /// /// Wir nutzen bewusst nur eine Farbe (Amber) — selbst bei 5–10 möglichen /// Lagern bleibt die Karte mit einem zusätzlichen Lagernamen als Text /// lesbar. Mehrere Lager-Farben hätten zu Konfusion geführt und einzelne /// Lager nicht eindeutig zugeordnet. /// -/// [warehouseNames] ist optional: ohne Namen erscheint nur "Außenlager". +/// [warehouseNames] ist optional: ohne Namen erscheint nur "Filiale". class WarehouseBadge extends StatelessWidget { const WarehouseBadge({ super.key, @@ -66,7 +66,7 @@ class WarehouseBadge extends StatelessWidget { } String _buildLabel() { - if (warehouseNames.isEmpty) return "Außenlager"; + if (warehouseNames.isEmpty) return "Filiale"; if (warehouseNames.length == 1) return warehouseNames.first; return warehouseNames.join(" + "); } diff --git a/openapi/holzleitner.json b/openapi/holzleitner.json index 0adf843..e7562ef 100644 --- a/openapi/holzleitner.json +++ b/openapi/holzleitner.json @@ -57,6 +57,267 @@ ] } }, + "/admin/delivered-belegnummern": { + "get": { + "tags": [ + "admin" + ], + "summary": "Liefert die Belegnummern ausgelieferter (abgeschlossener) Lieferungen,\n**deren Liefermail noch nicht versendet wurde** (`mail_sent_at IS NULL`).\n\u201eAusgeliefert\" = es existiert ein Abschluss. Mit `day` (DD-MM-YYYY) nur\nAbschl\u00fcsse dieses Berliner Kalendertages; **ohne `day` alle offenen** (\u00fcber\nalle Tage) \u2014 so bleiben Belege \u00fcber Mitternacht nicht h\u00e4ngen.", + "operationId": "delivered_belegnummern", + "parameters": [ + { + "name": "day", + "in": "query", + "description": "Tag DD-MM-YYYY; ohne Angabe ALLE offenen Belege", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Offene (nicht versendete) Belegnummern", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeliveredBelegnummernResponse" + } + } + } + }, + "400": { + "description": "Ung\u00fcltiger Tag" + }, + "401": { + "description": "Admin-API-Key fehlt/ung\u00fcltig" + } + }, + "security": [ + { + "admin_api_key": [] + } + ] + } + }, + "/admin/import-erp": { + "post": { + "tags": [ + "admin" + ], + "summary": "St\u00f6\u00dft den ERP-Import f\u00fcr ein Datum an und liefert die Zusammenfassung.", + "operationId": "import_erp", + "parameters": [ + { + "name": "date", + "in": "query", + "description": "Ziel-Tourdatum YYYY-MM-DD (Default: heute)", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Import durchgef\u00fchrt", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImportSummary" + } + } + } + }, + "400": { + "description": "Ung\u00fcltiges Datum" + }, + "401": { + "description": "Admin-API-Key fehlt/ung\u00fcltig" + }, + "502": { + "description": "ERP nicht erreichbar / Lesefehler" + } + }, + "security": [ + { + "admin_api_key": [] + } + ] + } + }, + "/admin/mark-mail-sent": { + "post": { + "tags": [ + "admin" + ], + "summary": "Markiert die Liefermails der angegebenen Belegnummern als **versendet**\n(`mail_sent_at = now()`, nur wo noch offen). Vom Mailclient aufzurufen,\nNACHDEM ERPframe die Mails erfolgreich verschickt hat \u2014 danach erscheinen\ndie Belege nicht mehr in `GET /admin/delivered-belegnummern`.", + "operationId": "mark_mail_sent", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MarkMailSentRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Markierung durchgef\u00fchrt", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MarkMailSentResponse" + } + } + } + }, + "401": { + "description": "Admin-API-Key fehlt/ung\u00fcltig" + } + }, + "security": [ + { + "admin_api_key": [] + } + ] + } + }, + "/admin/push-completion": { + "post": { + "tags": [ + "admin" + ], + "summary": "St\u00f6\u00dft das ERP-R\u00fcckschreiben eines bereits lokal abgeschlossenen\nLieferabschlusses erneut an (idempotenter Retry, falls der automatische\nPush beim Abschluss fehlschlug).", + "operationId": "push_completion", + "parameters": [ + { + "name": "delivery_id", + "in": "query", + "description": "UUID der abgeschlossenen Lieferung", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "R\u00fcckschreiben erfolgreich" + }, + "400": { + "description": "Ung\u00fcltige delivery_id" + }, + "401": { + "description": "Admin-API-Key fehlt/ung\u00fcltig" + }, + "404": { + "description": "Lieferung nicht gefunden / nicht abgeschlossen" + }, + "502": { + "description": "ERP nicht erreichbar / Schreibfehler" + } + }, + "security": [ + { + "admin_api_key": [] + } + ] + } + }, + "/attachments/{id}": { + "get": { + "tags": [ + "attachments" + ], + "summary": "Liefert ein gerendertes Vorschaubild des Attachments (Bytes), geladen\naus DOCUframe. Aufl\u00f6sung/Format \u00fcber Query-Parameter steuerbar\n(`?w=&h=&q=&ext=&page=`).", + "operationId": "get_attachment", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Attachment-Id (unsere UUID)", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "w", + "in": "query", + "description": "Breite in Pixeln (Default 1024)", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "minimum": 0 + } + }, + { + "name": "h", + "in": "query", + "description": "H\u00f6he in Pixeln (Default 1024)", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "minimum": 0 + } + }, + { + "name": "q", + "in": "query", + "description": "Qualit\u00e4t 0\u2013100 (Default 85)", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "minimum": 0 + } + }, + { + "name": "ext", + "in": "query", + "description": "png|jpeg|jpg|webp|tiff (Default jpeg)", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "page", + "in": "query", + "description": "Seitennummer (Default 1)", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Vorschaubild (Bytes)", + "content": { + "image/jpeg": {} + } + }, + "401": { + "description": "Authentifizierung fehlgeschlagen" + }, + "404": { + "description": "Attachment nicht gefunden" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, "/deliveries/{delivery_id}/assigned-car": { "put": { "tags": [ @@ -175,6 +436,7 @@ "deliveries" ], "summary": "Schlie\u00dft die Lieferung ab \u2014 `state = completed`. Nur aus `active`.", + "description": "`multipart/form-data` mit drei Feldern:\n * `customer_signature` \u2014 PNG der Kunden-Unterschrift (Pflicht)\n * `driver_signature` \u2014 PNG der Fahrer-Unterschrift (Pflicht)\n * `acknowledgements` \u2014 JSON (`CompleteDeliveryAcknowledgements`):\n `receiptConfirmed` (Pflicht true), `notesAcknowledged`,\n `acknowledgedNoteIds`, `authorCarId`.\n\nAtomar: Signaturen werden lokal gespeichert, die Abschluss-Zeile\ngeschrieben und der Status auf `completed` gesetzt \u2014 alles oder nichts.\nGates: Lieferung aktiv, alle scanbaren Positionen fertig, Notizen\nbest\u00e4tigt (falls vorhanden).", "operationId": "complete", "parameters": [ { @@ -187,6 +449,12 @@ } } ], + "requestBody": { + "description": "Felder `customer_signature`, `driver_signature` (PNG) + `acknowledgements` (JSON)", + "content": { + "multipart/form-data": {} + } + }, "responses": { "200": { "description": "Lieferung abgeschlossen", @@ -199,7 +467,63 @@ } }, "400": { - "description": "Invalider Status\u00fcbergang" + "description": "Invalider Status\u00fcbergang / fehlende Signatur / offene Scans / Notizen unbest\u00e4tigt" + }, + "401": { + "description": "Authentifizierung fehlgeschlagen" + }, + "404": { + "description": "Lieferung nicht gefunden" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/deliveries/{delivery_id}/credit": { + "post": { + "tags": [ + "deliveries" + ], + "summary": "Wendet ein Betrags-Gutschrift-Ereignis an (`set`/`remove`). Append-only,\nidempotent \u00fcber `clientEventId`. Nur bei aktiver Lieferung; bei `set` sind\nBetrag (0 < x \u2264 150 \u20ac, 10-\u20ac-Schritte) und Grund Pflicht. Antwort: der\naktuelle Gutschrift-Stand (`null`, wenn entfernt).", + "operationId": "apply_credit", + "parameters": [ + { + "name": "delivery_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeliveryCreditEventRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Gutschrift gesetzt/entfernt", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeliveryCreditResponse" + } + } + } + }, + "400": { + "description": "Ung\u00fcltiger Betrag/Grund oder Lieferung nicht aktiv" }, "401": { "description": "Authentifizierung fehlgeschlagen" @@ -327,6 +651,169 @@ ] } }, + "/deliveries/{delivery_id}/notes/image": { + "post": { + "tags": [ + "deliveries" + ], + "summary": "L\u00e4dt ein Bild zu einer Lieferung hoch (multipart/form-data, Feld `file`)\nund legt daf\u00fcr eine Bild-Notiz an. Das Bild geht in den\nDOCUframe-Dokumentenspeicher; gespeichert wird die zur\u00fcckgelieferte\nReferenz (`~ObjectID`) als `image_attachment` der Notiz.", + "operationId": "upload_note_image", + "parameters": [ + { + "name": "delivery_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "description": "Formularfeld `file` mit den Bilddaten", + "content": { + "multipart/form-data": {} + } + }, + "responses": { + "200": { + "description": "Bild hochgeladen, Notiz angelegt", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeliveryNoteResponse" + } + } + } + }, + "400": { + "description": "Kein/leeres Datei-Feld" + }, + "401": { + "description": "Authentifizierung fehlgeschlagen" + }, + "404": { + "description": "Lieferung nicht gefunden" + }, + "500": { + "description": "Upload zu DOCUframe fehlgeschlagen" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/deliveries/{delivery_id}/notes/{note_id}": { + "delete": { + "tags": [ + "deliveries" + ], + "summary": "L\u00f6scht eine Notiz. Antwortet mit `204 No Content`.", + "operationId": "delete_note", + "parameters": [ + { + "name": "delivery_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "note_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "204": { + "description": "Notiz gel\u00f6scht" + }, + "401": { + "description": "Authentifizierung fehlgeschlagen" + }, + "404": { + "description": "Notiz nicht gefunden" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + }, + "patch": { + "tags": [ + "deliveries" + ], + "summary": "\u00c4ndert Text/Bild einer Notiz. Innerhalb des (geteilten) Accounts darf\njeder Fahrer Notizen pflegen \u2014 kein Autor-Check. `delivery_id` ist Teil\ndes Pfads (REST-Konsistenz), die Notiz wird \u00fcber `note_id` adressiert.", + "operationId": "update_note", + "parameters": [ + { + "name": "delivery_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "note_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateDeliveryNoteRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Notiz aktualisiert", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeliveryNoteResponse" + } + } + } + }, + "400": { + "description": "Notiz ohne Inhalt" + }, + "401": { + "description": "Authentifizierung fehlgeschlagen" + }, + "404": { + "description": "Notiz nicht gefunden" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, "/deliveries/{delivery_id}/resume": { "post": { "tags": [ @@ -373,6 +860,117 @@ ] } }, + "/deliveries/{delivery_id}/services/{service_id}": { + "put": { + "tags": [ + "deliveries" + ], + "summary": "Setzt (Upsert) den Wert eines Service f\u00fcr eine Lieferung. Genau das zum\nService-Typ passende Feld (`boolValue`/`numericValue`) muss gesetzt sein;\nnumerische Werte werden gegen min/max gepr\u00fcft. Nur bei aktiver Lieferung.", + "operationId": "set_service", + "parameters": [ + { + "name": "delivery_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "service_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SetDeliveryServiceRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Wert gesetzt", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeliveryServiceResponse" + } + } + } + }, + "400": { + "description": "Wert passt nicht zum Service-Typ / au\u00dferhalb min-max / Lieferung nicht aktiv" + }, + "401": { + "description": "Authentifizierung fehlgeschlagen" + }, + "404": { + "description": "Service oder Lieferung nicht gefunden" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + }, + "delete": { + "tags": [ + "deliveries" + ], + "summary": "Entfernt den Service-Wert einer Lieferung (Service \u201enicht gesetzt\").\nNur bei aktiver Lieferung. Antwort `204`.", + "operationId": "delete_service_value", + "parameters": [ + { + "name": "delivery_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "service_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "204": { + "description": "Wert entfernt" + }, + "400": { + "description": "Lieferung nicht aktiv" + }, + "401": { + "description": "Authentifizierung fehlgeschlagen" + }, + "404": { + "description": "Lieferung nicht gefunden" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, "/health": { "get": { "tags": [ @@ -560,6 +1158,178 @@ ] } }, + "/payment-methods": { + "get": { + "tags": [ + "payment-methods" + ], + "summary": "Listet die Zahlungsmethoden.", + "operationId": "list_payment_methods", + "parameters": [ + { + "name": "includeInactive", + "in": "query", + "description": "Wenn true, werden inaktive Methoden mitgeliefert (default: false)", + "required": false, + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "200": { + "description": "Zahlungsmethoden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaymentMethodsList" + } + } + } + }, + "401": { + "description": "Authentifizierung fehlgeschlagen" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + }, + "post": { + "tags": [ + "payment-methods" + ], + "summary": "Legt eine neue Zahlungsmethode an.", + "operationId": "create_payment_method", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreatePaymentMethodRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaymentMethodResponse" + } + } + } + }, + "400": { + "description": "Validierungsfehler (z. B. doppelter code)" + }, + "401": { + "description": "Authentifizierung fehlgeschlagen" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/payment-methods/{id}": { + "delete": { + "tags": [ + "payment-methods" + ], + "summary": "Hartes L\u00f6schen. `409 Conflict`, wenn die Methode von einer Lieferung\nreferenziert wird \u2014 der Admin soll dann den `active = false`-Pfad\nnutzen.", + "operationId": "delete_payment_method", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Zahlungsmethoden-Id", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "204": { + "description": "Methode gel\u00f6scht" + }, + "401": { + "description": "Authentifizierung fehlgeschlagen" + }, + "404": { + "description": "Methode nicht gefunden" + }, + "409": { + "description": "Methode ist noch von Lieferungen referenziert" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + }, + "patch": { + "tags": [ + "payment-methods" + ], + "summary": "Patcht Anzeige-Name und/oder Aktiv-Flag.", + "operationId": "update_payment_method", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Zahlungsmethoden-Id", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdatePaymentMethodRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaymentMethodResponse" + } + } + } + }, + "401": { + "description": "Authentifizierung fehlgeschlagen" + }, + "404": { + "description": "Methode nicht gefunden" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, "/scans": { "post": { "tags": [ @@ -600,6 +1370,181 @@ ] } }, + "/services": { + "get": { + "tags": [ + "services" + ], + "summary": "Listet die Services (sortiert nach `sortOrder`).", + "operationId": "list_services", + "parameters": [ + { + "name": "includeInactive", + "in": "query", + "description": "Wenn true, werden inaktive Services mitgeliefert (default: false)", + "required": false, + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "200": { + "description": "Services", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ServicesList" + } + } + } + }, + "401": { + "description": "Authentifizierung fehlgeschlagen" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + }, + "post": { + "tags": [ + "services" + ], + "summary": "Legt einen neuen Service an.", + "operationId": "create_service", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateServiceRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ServiceResponse" + } + } + } + }, + "400": { + "description": "Validierungsfehler (z. B. kind/min/max inkonsistent)" + }, + "401": { + "description": "Authentifizierung fehlgeschlagen" + }, + "409": { + "description": "key existiert bereits" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/services/{id}": { + "delete": { + "tags": [ + "services" + ], + "summary": "Hartes L\u00f6schen. `409 Conflict`, wenn der Service noch von einer Lieferung\nreferenziert wird \u2014 dann stattdessen deaktivieren.", + "operationId": "delete_service", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Service-Id", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "204": { + "description": "Service gel\u00f6scht" + }, + "401": { + "description": "Authentifizierung fehlgeschlagen" + }, + "404": { + "description": "Service nicht gefunden" + }, + "409": { + "description": "Service ist noch referenziert" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + }, + "patch": { + "tags": [ + "services" + ], + "summary": "Patcht Name/Grenzen/Aktiv-Flag/Sortierung. `kind` ist nicht \u00e4nderbar.", + "operationId": "update_service", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Service-Id", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateServiceRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ServiceResponse" + } + } + } + }, + "401": { + "description": "Authentifizierung fehlgeschlagen" + }, + "404": { + "description": "Service nicht gefunden" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, "/sync/tour": { "post": { "tags": [ @@ -869,13 +1814,14 @@ }, "AuditAction": { "type": "string", - "description": "Aktion-Typen im Scan-Audit-Log.\n\n* `Scan` / `Unscan` ver\u00e4ndern die `scanned_quantity` (+1 / -1).\n* `Hold` / `Unhold` \u00e4ndern nur den Status, keine Menge.\n* `Remove` markiert die Position als entfernt (Status `Removed`,\n z. B. weil der Kunde sie nicht annimmt).", + "description": "Aktion-Typen im Scan-Audit-Log.\n\n* `Scan` / `Unscan` ver\u00e4ndern die `scanned_quantity` (+1 / -1).\n* `Hold` / `Unhold` \u00e4ndern nur den Status, keine Menge.\n* `Remove` markiert die Position als entfernt (Status `Removed`,\n z. B. weil der Kunde sie nicht annimmt).\n* `Unremove` hebt ein `Remove` wieder auf \u2014 die Position landet\n zur\u00fcck in `InProgress` (oder `Done`, falls die `scanned_quantity`\n schon `required_quantity` erreicht hatte). Der urspr\u00fcngliche\n `Remove`-Audit-Eintrag bleibt unangetastet; das `Unremove` erzeugt\n einen eigenen Audit-Eintrag \u2014 die Historie bleibt vollst\u00e4ndig.", "enum": [ "scan", "unscan", "hold", "unhold", - "remove" + "remove", + "unremove" ] }, "CancelDeliveryRequest": { @@ -941,6 +1887,167 @@ } } }, + "CompleteDeliveryAcknowledgements": { + "type": "object", + "description": "Dokumentierte Best\u00e4tigungen des Kunden zum Abschlusszeitpunkt.", + "required": [ + "receiptConfirmed" + ], + "properties": { + "acknowledgedNoteIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "description": "Notiz-IDs, die zum Abschlusszeitpunkt sichtbar waren und mit-best\u00e4tigt\nwurden (Audit-Robustheit)." + }, + "authorCarId": { + "type": [ + "string", + "null" + ], + "format": "uuid", + "description": "Fahrzeug des Akteurs (Audit-Spur). Muss zum Account geh\u00f6ren." + }, + "notesAcknowledged": { + "type": "boolean", + "description": "\u201eAnmerkungen zur Lieferung zur Kenntnis genommen.\" \u2014 Pflicht nur, wenn\nNotizen existieren (das pr\u00fcft der Server)." + }, + "paymentCollected": { + "type": "boolean", + "description": "Inkasso-Best\u00e4tigung des Fahrers: \u201eder offene Betrag wurde erhalten\n(bar) bzw. \u00fcber das EC-Ger\u00e4t abgerechnet.\" Pflicht nur, wenn beim\nAbschluss ein offener Betrag > 0 besteht UND die Methode ein Vor-Ort-\nInkasso ist (Bar/EC) \u2014 das pr\u00fcft der Server. Der kassierte Betrag wird\nserver-seitig autoritativ berechnet (nicht vom Client \u00fcbernommen)." + }, + "paymentMethodId": { + "type": [ + "string", + "null" + ], + "format": "uuid", + "description": "Optionale Zahlungsmethode, die der Fahrer beim Abschluss gew\u00e4hlt hat.\n`None` = die am Beleg hinterlegte Methode bleibt. Falls gesetzt, muss\nsie existieren **und** aktiv sein (vom Server gepr\u00fcft)." + }, + "receiptConfirmed": { + "type": "boolean", + "description": "\u201eWare im ordnungsgem\u00e4\u00dfen Zustand erhalten / Aufbau korrekt.\" \u2014 Pflicht." + } + } + }, + "ContactChannel": { + "type": "object", + "description": "Ein einzelner Kontaktkanal (Telefonnummer / Mobil / E-Mail / Web).\nMehrere pro [`ContactSource`] m\u00f6glich, die `position` h\u00e4lt die\n1-basierte ERP-Reihenfolge (`Telefon` \u2192 1, `Telefon2` \u2192 2 usw.) fest,\ndamit der \u201eprim\u00e4re\" Kanal je Art stabil identifizierbar bleibt.", + "required": [ + "id", + "sourceId", + "kind", + "position", + "value" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "kind": { + "$ref": "#/components/schemas/ContactKind" + }, + "position": { + "type": "integer", + "format": "int32" + }, + "sourceId": { + "type": "string", + "format": "uuid" + }, + "value": { + "type": "string" + } + } + }, + "ContactKind": { + "type": "string", + "description": "Art eines Kommunikationskanals. `fax` bewusst nicht mitgef\u00fchrt \u2014 in der\nApp nicht verwendet.", + "enum": [ + "phone", + "mobile", + "email", + "web" + ] + }, + "ContactRole": { + "type": "string", + "description": "Rolle, mit der ein Kontakt-Datensatz an einer Lieferung h\u00e4ngt. Spiegelt\ndie f\u00fcnf Adress-FKs von `Belegkopf` (bzw. den Umweg \u00fcber den Kunden):\n`header` = Belegadresse, `delivery` = Lieferadresse, `billing` =\nRechnungsadresse, `contact_person` = Ansprechpartner, `customer_master`\n= Stammadresse des Kunden \u00fcber `Kunden.AdressId`.", + "enum": [ + "header", + "delivery", + "billing", + "contact_person", + "customer_master" + ] + }, + "ContactSource": { + "type": "object", + "description": "Snapshot eines ERP-Adress-Datensatzes, der zum Zeitpunkt des Tour-Syncs\nan einer Lieferung hing \u2014 Namensblock ohne Anschrift, weil die Adresse\nihrerseits schon im Lieferungs-Snapshot steckt (`snap_*`-Spalten). Die\neigentlichen Telefonnummern, E-Mails etc. liegen in den\nzugeh\u00f6rigen [`ContactChannel`]s.", + "required": [ + "id", + "deliveryId", + "role" + ], + "properties": { + "abteilung": { + "type": [ + "string", + "null" + ] + }, + "anrede": { + "type": [ + "string", + "null" + ] + }, + "deliveryId": { + "type": "string", + "format": "uuid" + }, + "funktion": { + "type": [ + "string", + "null" + ] + }, + "id": { + "type": "string", + "format": "uuid" + }, + "name1": { + "type": [ + "string", + "null" + ] + }, + "name2": { + "type": [ + "string", + "null" + ] + }, + "name3": { + "type": [ + "string", + "null" + ] + }, + "role": { + "$ref": "#/components/schemas/ContactRole" + }, + "titel": { + "type": [ + "string", + "null" + ] + } + } + }, "CreateCarRequest": { "type": "object", "required": [ @@ -963,6 +2070,14 @@ "format": "uuid", "description": "Fahrzeug, das die Notiz erzeugt hat. Muss zum angemeldeten\nAccount geh\u00f6ren. `None` ist erlaubt." }, + "creditDeliveryItemId": { + "type": [ + "string", + "null" + ], + "format": "uuid", + "description": "Optionaler Gutschrift-Bezug: die Belegzeile, f\u00fcr die diese Notiz als\nGutschrift-Grund angelegt wird. Erm\u00f6glicht das gezielte L\u00f6schen beim\nUnremove. `None` f\u00fcr normale Notizen." + }, "imageAttachment": { "type": [ "string", @@ -970,6 +2085,10 @@ ], "description": "Object-Storage-Key oder URL eines vorab hochgeladenen Bildes." }, + "isAmountCreditNote": { + "type": "boolean", + "description": "`true` markiert die Notiz als Grund einer Betrags-Gutschrift\n(Lieferungs-Ebene). Default `false`." + }, "text": { "type": [ "string", @@ -978,6 +2097,73 @@ } } }, + "CreatePaymentMethodRequest": { + "type": "object", + "required": [ + "code", + "name" + ], + "properties": { + "code": { + "type": "string", + "description": "Eindeutiger Programm-Identifier (z. B. `\"paypal\"`, `\"klarna\"`)." + }, + "name": { + "type": "string", + "description": "Anzeige-Name in der UI." + } + } + }, + "CreateServiceRequest": { + "type": "object", + "required": [ + "key", + "name", + "kind" + ], + "properties": { + "key": { + "type": "string", + "description": "Eindeutiger Programm-Identifier (z. B. `\"podium_setup\"`)." + }, + "kind": { + "$ref": "#/components/schemas/ServiceKind" + }, + "maxValue": { + "type": [ + "integer", + "null" + ], + "format": "int32" + }, + "minValue": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Nur bei `Numeric` sinnvoll." + }, + "name": { + "type": "string" + }, + "sortOrder": { + "type": [ + "integer", + "null" + ], + "format": "int32" + } + } + }, + "CreditAction": { + "type": "string", + "description": "Art des Gutschrift-Ereignisses.", + "enum": [ + "set", + "remove" + ] + }, "Customer": { "type": "object", "description": "Kunde. ERP-Mirror: die Stammdaten geh\u00f6ren dem ERP, wir spiegeln sie\nf\u00fcr die App. Die `erp_customer_id` ist die Br\u00fccke zur\u00fcck (in der\nRegel die `Kunde.row_id` aus ERPframe).\n\nDie `Customer.address` ist die *aktuelle* Anschrift. F\u00fcr historische\nStabilit\u00e4t f\u00fchrt [`crate::domain::Delivery`] zus\u00e4tzlich einen\n`delivery_address_snapshot` \u2014 Adress-\u00c4nderungen wirken nicht\nr\u00fcckwirkend auf bereits zugestellte oder geplante Lieferungen.", @@ -1038,6 +2224,32 @@ } } }, + "DeliveredBelegnummernResponse": { + "type": "object", + "required": [ + "day", + "count", + "belegnummern" + ], + "properties": { + "belegnummern": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Belegnummern aller **ausgelieferten** (abgeschlossenen) Lieferungen,\nderen Liefermail noch **nicht versendet** wurde, aufsteigend nach\nAbschluss-Zeitpunkt." + }, + "count": { + "type": "integer", + "description": "Anzahl der offenen (noch nicht versendeten) Belege.", + "minimum": 0 + }, + "day": { + "type": "string", + "description": "Tag, nach dem gefiltert wurde (ISO `YYYY-MM-DD`), oder `\"all\"` wenn kein\n`day` angegeben war." + } + } + }, "Delivery": { "type": "object", "description": "Eine einzelne Lieferung an einen Kunden. Aggregat-Wurzel f\u00fcr die\nLiefer-Items, Notizen und das ggf. zugeordnete Fahrzeug.", @@ -1049,7 +2261,9 @@ "customerId", "deliveryAddressSnapshot", "contactPersonIds", - "state" + "state", + "prepaidAmount", + "paymentMethodId" ], "properties": { "assignedCarId": { @@ -1095,6 +2309,16 @@ "type": "string", "format": "uuid" }, + "paymentMethodId": { + "type": "string", + "format": "uuid", + "description": "F\u00fcr den Restbetrag gew\u00e4hlte Zahlungsart \u2014 FK auf `payment_methods`.\nVom Kunden bei Bestellung festgelegt, der Fahrer \u00fcbernimmt nur\ndie Abwicklung. Aktiv-Flag und Anzeige-Name werden \u00fcber die\nStammdaten-Tabelle aufgel\u00f6st, nicht hier embeddet." + }, + "prepaidAmount": { + "type": "number", + "format": "double", + "description": "Bei Bestellung schon bezahlter Betrag in EUR. `0.0` wenn der\nKunde alles bei Lieferung zahlt. Wird vom ERP-Sync gef\u00fcllt." + }, "specialAgreements": { "type": [ "string", @@ -1118,6 +2342,85 @@ } } }, + "DeliveryCredit": { + "type": "object", + "description": "Aktuelle Betrags-Gutschrift einer Lieferung (Geld-Nachlass, unabh\u00e4ngig von\nSt\u00fcckzahl). Abgeleitet aus dem j\u00fcngsten Ereignis im append-only\n`delivery_credit_audit`; existiert nur, solange der letzte Stand `set`\n(und nicht `remove`) ist. `delivery_id` macht den Eintrag \u2014 wie eine\nNotiz \u2014 clientseitig per Lieferung join-bar.", + "required": [ + "deliveryId", + "amountCents", + "reason" + ], + "properties": { + "amountCents": { + "type": "integer", + "format": "int64", + "description": "Gutschrift-Betrag in Cent (> 0, \u2264 15000)." + }, + "deliveryId": { + "type": "string", + "format": "uuid" + }, + "reason": { + "type": "string" + } + } + }, + "DeliveryCreditEventRequest": { + "type": "object", + "required": [ + "clientEventId", + "action" + ], + "properties": { + "action": { + "$ref": "#/components/schemas/CreditAction" + }, + "amountCents": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "description": "Bei `Set` Pflicht: Betrag in Cent (> 0, \u2264 15000, Vielfaches von 1000)." + }, + "authorCarId": { + "type": [ + "string", + "null" + ], + "format": "uuid", + "description": "Fahrzeug des Akteurs (Audit-Spur). Muss zum Account geh\u00f6ren." + }, + "clientEventId": { + "type": "string", + "format": "uuid", + "description": "Idempotenz-Schl\u00fcssel \u2014 pro erzeugtem Ereignis genau einmal vergeben.\nEin Retry mit derselben Id wendet nichts erneut an." + }, + "reason": { + "type": [ + "string", + "null" + ], + "description": "Bei `Set` Pflicht: Begr\u00fcndung." + } + } + }, + "DeliveryCreditResponse": { + "type": "object", + "properties": { + "credit": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/DeliveryCredit", + "description": "Aktueller Stand nach dem Ereignis \u2014 `None`, wenn (zuletzt) entfernt." + } + ] + } + } + }, "DeliveryItem": { "type": "object", "description": "Einzelposition einer Lieferung. Vereint regul\u00e4re Belegzeilen und\nSt\u00fccklisten-Komponenten zu einer flachen Liste \u2014 die St\u00fccklisten-\nHierarchie ist ein ERP-Konstrukt und wird beim Sync aufgel\u00f6st.\n\n\u00dcber die Felder `belegzeilen_nr` und `komponenten_artikel_nr` bleibt\ndie ERP-Herkunft aufl\u00f6sbar.", @@ -1127,6 +2430,7 @@ "articleId", "requiredQuantity", "warehouseId", + "unitPrice", "belegzeilenNr", "scanState" ], @@ -1155,6 +2459,13 @@ ], "description": "Bei Items aus einer St\u00fcckliste: Artikelnummer der Komponente.\nBei regul\u00e4ren Belegzeilen: `None`." }, + "parentArtikelNr": { + "type": [ + "string", + "null" + ], + "description": "Artikelnummer des Oberartikels, zu dem diese Komponente geh\u00f6rt.\n`None` bei Oberartikeln/regul\u00e4ren Zeilen \u2014 die App r\u00fcckt Komponenten\ndar\u00fcber unter ihrem Oberartikel ein." + }, "requiredQuantity": { "type": "integer", "format": "int32" @@ -1162,6 +2473,11 @@ "scanState": { "$ref": "#/components/schemas/ScanState" }, + "unitPrice": { + "type": "number", + "format": "double", + "description": "St\u00fcckpreis (brutto, EUR) aus dem ERP-Sync. Der Warenwert einer\nLieferung = \u03a3 `unit_price` \u00d7 ausgelieferte Menge." + }, "warehouseId": { "type": "string", "format": "uuid" @@ -1175,6 +2491,7 @@ "id", "deliveryId", "authorPersonalnummer", + "isAmountCreditNote", "createdAt" ], "properties": { @@ -1195,6 +2512,14 @@ "type": "string", "format": "date-time" }, + "creditDeliveryItemId": { + "type": [ + "string", + "null" + ], + "format": "uuid", + "description": "Wenn die Notiz als Gutschrift-Grund zu einer Belegzeile angelegt\nwurde: deren `DeliveryItem`-Id. Erlaubt dem Client, die Notiz beim\nZur\u00fccknehmen der Gutschrift (Unremove) gezielt wieder zu l\u00f6schen.\n`None` bei normalen Text-/Foto-Notizen." + }, "deliveryId": { "type": "string", "format": "uuid" @@ -1210,6 +2535,14 @@ ], "description": "Referenz auf einen Bild-Anhang (z. B. Object-Storage-Key/URL)." }, + "imageAttachmentDeleted": { + "type": "boolean", + "description": "`true`, wenn die lokale Bilddatei nach erfolgreichem Report-Upload\ngel\u00f6scht wurde (das Bild steckt nun im Lieferbericht in DOCUframe).\nRead-only; die App zeigt dann statt der Vorschau einen Hinweis.\nBei Text-Notizen / vorhandenem Bild: `false`." + }, + "isAmountCreditNote": { + "type": "boolean", + "description": "`true`, wenn die Notiz den Grund einer **Betrags-Gutschrift**\n(Geld-Nachlass, Lieferungs-Ebene) dokumentiert. Erlaubt dem Client,\nsie beim Entfernen der Gutschrift gezielt zu l\u00f6schen." + }, "text": { "type": [ "string", @@ -1257,6 +2590,48 @@ } } }, + "DeliveryServiceResponse": { + "type": "object", + "required": [ + "value" + ], + "properties": { + "value": { + "$ref": "#/components/schemas/DeliveryServiceValue" + } + } + }, + "DeliveryServiceValue": { + "type": "object", + "description": "Pro-Lieferung gew\u00e4hlter Wert eines Service. Genau einer der beiden\nWert-Slots ist je nach `ServiceKind` gesetzt; per `service_id`/`delivery_id`\nclientseitig join-bar (wie Notizen/Gutschriften).", + "required": [ + "deliveryId", + "serviceId" + ], + "properties": { + "boolValue": { + "type": [ + "boolean", + "null" + ] + }, + "deliveryId": { + "type": "string", + "format": "uuid" + }, + "numericValue": { + "type": [ + "integer", + "null" + ], + "format": "int32" + }, + "serviceId": { + "type": "string", + "format": "uuid" + } + } + }, "DeliveryState": { "type": "string", "description": "Lebenszyklus einer Lieferung.\n\n`Held` ist f\u00fcr \u201eheute nicht zustellbar, aber nicht endg\u00fcltig abgesagt\"\nreserviert; `Canceled` ist endg\u00fcltig. `Completed` setzt der\nAbschluss-Flow am Ende der Auslieferung.", @@ -1305,6 +2680,140 @@ } } }, + "ImportSummary": { + "type": "object", + "description": "Ergebnis eines Import-Laufs \u2014 pro Fahrer-Tour Erfolg/Fehler getrennt,\ndamit ein einzelner kaputter Beleg nicht den ganzen Tag blockiert.", + "required": [ + "date", + "toursTotal", + "toursOk", + "toursFailed", + "errors" + ], + "properties": { + "date": { + "type": "string", + "format": "date" + }, + "driversProvisioned": { + "type": "integer", + "description": "Anzahl der **neu** im Identity-Provider (Keycloak) angelegten\nFahrer-Konten in diesem Lauf (0, wenn Provisionierung deaktiviert ist\noder alle Konten bereits existierten).", + "minimum": 0 + }, + "errors": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Fehlertexte je fehlgeschlagener Fahrer-Tour (z. B. unbekannter Fahrer\n\u2192 FK auf `accounts`, oder Validierungsfehler)." + }, + "provisioningErrors": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Fehlertexte der Konto-Provisionierung (Keycloak). Best-effort: ein\nFehler hier blockiert den Touren-Import **nicht**." + }, + "toursFailed": { + "type": "integer", + "minimum": 0 + }, + "toursOk": { + "type": "integer", + "minimum": 0 + }, + "toursTotal": { + "type": "integer", + "minimum": 0 + } + } + }, + "MarkMailSentRequest": { + "type": "object", + "required": [ + "belegnummern" + ], + "properties": { + "belegnummern": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Belegnummern, deren Liefermail erfolgreich versendet wurde und die als\nversendet markiert werden sollen." + } + } + }, + "MarkMailSentResponse": { + "type": "object", + "required": [ + "marked" + ], + "properties": { + "marked": { + "type": "integer", + "format": "int64", + "description": "Anzahl frisch markierter (vorher offener) Belege. Bereits markierte\nz\u00e4hlen nicht mit (idempotent).", + "minimum": 0 + } + } + }, + "PaymentMethod": { + "type": "object", + "description": "Zahlungs-Stammdatensatz.\n\nBewusst eine Tabelle und kein Enum: neue Anbieter (PayPal, Klarna, \u2026)\nkommen \u00fcber den `POST /payment-methods`-Endpoint hinzu. Domain-Code\nkann trotzdem fachliche Sonderf\u00e4lle \u00fcber den stabilen `code` (z. B.\n`\"invoice\"` braucht Bonit\u00e4tspr\u00fcfung) referenzieren \u2014 die UUID dient\nnur als FK in `deliveries`.\n\n`active = false` ist Soft-Delete: die Methode bleibt referenzierbar\nf\u00fcr historische Lieferungen, taucht aber in der UI-Auswahl nicht\nmehr auf. Echtes L\u00f6schen ist nur m\u00f6glich, wenn keine Lieferung sie\nreferenziert \u2014 Datenbank-Constraint regelt das via\n`ON DELETE RESTRICT`.", + "required": [ + "id", + "code", + "name", + "active", + "createdAt" + ], + "properties": { + "active": { + "type": "boolean" + }, + "code": { + "type": "string", + "description": "Stabiler Programm-Identifier \u2014 z. B. `\"cash\"`, `\"ec_card\"`.\nEindeutig pro Eintrag. Wird vom Aufrufer beim Anlegen gesetzt." + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string", + "description": "Display-Name in der UI \u2014 frei via PATCH \u00e4nderbar." + } + } + }, + "PaymentMethodResponse": { + "type": "object", + "required": [ + "method" + ], + "properties": { + "method": { + "$ref": "#/components/schemas/PaymentMethod" + } + } + }, + "PaymentMethodsList": { + "type": "object", + "required": [ + "methods" + ], + "properties": { + "methods": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PaymentMethod" + } + } + } + }, "ScanEvent": { "type": "object", "required": [ @@ -1337,6 +2846,18 @@ "type": "string", "format": "uuid" }, + "manual": { + "type": "boolean", + "description": "`true`, wenn der Fahrer die Position **manuell** als geladen best\u00e4tigt\nhat (Fallback ohne Barcode-Scan). Wird nur im Audit (`scan_audit.manual`)\nfestgehalten; an der Mengen-/Status-Logik \u00e4ndert es nichts. Default\n`false` (regul\u00e4rer Barcode-Scan)." + }, + "quantity": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Menge f\u00fcr `Remove` / `Unremove` (Mengen-Gutschrift): wie viele St\u00fcck\nder Belegzeile gutgeschrieben bzw. wieder hergestellt werden.\n`None` = ganze Restmenge (abw\u00e4rtskompatibel zum bisherigen\n\u201eganze Zeile entfernen\"). Bei `Scan`/`Unscan`/`Hold`/`Unhold`\nignoriert. Muss, wenn gesetzt, `> 0` sein." + }, "reason": { "type": [ "string", @@ -1400,10 +2921,16 @@ "description": "Eingebetteter Scan-Zustand pro [`DeliveryItem`]. Wird durch\n`ScanAuditEntry`-Events fortgeschrieben \u2014 das Audit-Log ist die\nWahrheit \u00fcber das WIE und WANN, dieses Embedded-VO ist die schnelle\nWahrheit \u00fcber das WIEVIEL.", "required": [ "scannedQuantity", + "creditedQuantity", "status", "lastUpdatedAt" ], "properties": { + "creditedQuantity": { + "type": "integer", + "format": "int32", + "description": "Als Gutschrift entfernte Menge (0..=required_quantity). Eigene\nDimension neben `scanned_quantity`: \u201ewie viele St\u00fcck dieser Zeile hat\nder Kunde nicht angenommen\". `status == Removed` entspricht\n`credited_quantity == required_quantity` (ganze Zeile gutgeschrieben)." + }, "heldReason": { "type": [ "string", @@ -1434,6 +2961,87 @@ "removed" ] }, + "Service": { + "type": "object", + "description": "Service-Stammdatensatz \u2014 admin-konfigurierbar (Muster wie `PaymentMethod`).\n\n`key` ist der stabile Programm-Identifier (eindeutig), `name` der\nAnzeige-Name. `min_value`/`max_value` sind nur f\u00fcr `Numeric` relevant.\n`active = false` ist Soft-Delete (bleibt f\u00fcr historische Lieferungen\nreferenzierbar, f\u00e4llt aus dem Default-Listing).", + "required": [ + "id", + "key", + "name", + "kind", + "active", + "sortOrder" + ], + "properties": { + "active": { + "type": "boolean" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "key": { + "type": "string" + }, + "kind": { + "$ref": "#/components/schemas/ServiceKind" + }, + "maxValue": { + "type": [ + "integer", + "null" + ], + "format": "int32" + }, + "minValue": { + "type": [ + "integer", + "null" + ], + "format": "int32" + }, + "name": { + "type": "string" + }, + "sortOrder": { + "type": "integer", + "format": "int32" + } + } + }, + "ServiceKind": { + "type": "string", + "description": "Eingabetyp eines Service (fr\u00fcher \u201eLieferoption\"). `Boolean` rendert als\nCheckbox, `Numeric` als Zahlenfeld mit optionalen Grenzen.", + "enum": [ + "boolean", + "numeric" + ] + }, + "ServiceResponse": { + "type": "object", + "required": [ + "service" + ], + "properties": { + "service": { + "$ref": "#/components/schemas/Service" + } + } + }, + "ServicesList": { + "type": "object", + "required": [ + "services" + ], + "properties": { + "services": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Service" + } + } + } + }, "SetDeliveryOrderRequest": { "type": "object", "required": [ @@ -1469,6 +3077,113 @@ } } }, + "SetDeliveryServiceRequest": { + "type": "object", + "description": "Setzt den Wert eines Service f\u00fcr eine Lieferung (Upsert). Es muss genau\ndas zum `ServiceKind` passende Feld gesetzt sein (Use Case pr\u00fcft das).", + "properties": { + "authorCarId": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "boolValue": { + "type": [ + "boolean", + "null" + ] + }, + "numericValue": { + "type": [ + "integer", + "null" + ], + "format": "int32" + } + } + }, + "SyncContactChannel": { + "type": "object", + "required": [ + "kind", + "position", + "value" + ], + "properties": { + "kind": { + "$ref": "#/components/schemas/ContactKind" + }, + "position": { + "type": "integer", + "format": "int32", + "description": "1-basiert: spiegelt ERP-Spaltenposition (Telefon \u2192 1, Telefon2 \u2192 2, \u2026)." + }, + "value": { + "type": "string" + } + } + }, + "SyncContactSource": { + "type": "object", + "description": "Eine Adress-Rolle eines Belegs mit Namensblock und allen ausgef\u00fcllten\nTelefon-/Mobil-/E-Mail-/Web-Eintr\u00e4gen.", + "required": [ + "role" + ], + "properties": { + "abteilung": { + "type": [ + "string", + "null" + ] + }, + "anrede": { + "type": [ + "string", + "null" + ] + }, + "channels": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SyncContactChannel" + } + }, + "funktion": { + "type": [ + "string", + "null" + ] + }, + "name1": { + "type": [ + "string", + "null" + ] + }, + "name2": { + "type": [ + "string", + "null" + ] + }, + "name3": { + "type": [ + "string", + "null" + ] + }, + "role": { + "$ref": "#/components/schemas/ContactRole" + }, + "titel": { + "type": [ + "string", + "null" + ] + } + } + }, "SyncDelivery": { "type": "object", "required": [ @@ -1482,13 +3197,34 @@ "items" ], "properties": { + "belegartCode": { + "type": [ + "string", + "null" + ], + "description": "Belegart-Kurzcode (z. B. \u201eVL5\"), aus `Belegarten.Belegart` (getrimmt)." + }, "belegartId": { "type": "integer", "format": "int64" }, + "belegartName": { + "type": [ + "string", + "null" + ], + "description": "Belegart-Klartext (z. B. \u201eLieferschein EH\"), aus `Belegarten.Bezeichnung`." + }, "belegnummer": { "type": "string" }, + "contactSources": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SyncContactSource" + }, + "description": "Alle vom ERP an diesem Beleg h\u00e4ngenden Kontakt-Adressen (Beleg-/\nLiefer-/Rechnungsadresse, Ansprechpartner, Kundenstamm). Leere\nQuellen (kein einziger ausgef\u00fcllter Kanal *und* kein Name) l\u00e4sst\nder Sync weg." + }, "customerAddress": { "$ref": "#/components/schemas/Address" }, @@ -1515,6 +3251,18 @@ "$ref": "#/components/schemas/SyncDeliveryItem" } }, + "paymentMethodCode": { + "type": [ + "string", + "null" + ], + "description": "F\u00fcr den Restbetrag gew\u00e4hlte Zahlungsart \u2014 Referenz per\n`code` (z. B. `\"cash\"`, `\"invoice\"`). Das ERP kennt seine\nStandard-Codes, der Sync-Code resolvet sie zur UUID. Wenn\n`None`, f\u00e4llt der Backend-Code auf `\"cash\"` zur\u00fcck." + }, + "prepaidAmount": { + "type": "number", + "format": "double", + "description": "Bei Bestellung schon bezahlter Betrag in EUR. Default `0.0`,\nwenn der Kunde alles bei Lieferung zahlt. Der ERP-Sync liefert\nden Wert mit." + }, "sortOrder": { "type": "integer", "format": "int32", @@ -1565,12 +3313,24 @@ "string", "null" ], - "description": "Komponenten-Artikelnummer bei aufgel\u00f6sten St\u00fccklisten, sonst leer." + "description": "Komponenten-Artikelnummer bei aufgel\u00f6sten St\u00fccklisten, sonst leer.\nTr\u00e4gt die **eigene** Nummer der Komponente (eindeutig je Belegzeile)." + }, + "parentArtikelNr": { + "type": [ + "string", + "null" + ], + "description": "Artikelnummer des **Oberartikels**, zu dem diese Komponente geh\u00f6rt.\n`None` bei Oberartikeln/regul\u00e4ren Zeilen. Erlaubt der App, Komponenten\nunter ihrem Oberartikel einzur\u00fccken." }, "requiredQuantity": { "type": "integer", "format": "int32" }, + "unitPrice": { + "type": "number", + "format": "double", + "description": "St\u00fcckpreis (brutto, EUR). Default `0.0`. Liefert der ERP-Sync mit;\ndie App rechnet daraus den Warenwert." + }, "warehouseCode": { "type": "string" }, @@ -1654,7 +3414,12 @@ "customerContacts", "articles", "warehouses", - "notes" + "notes", + "credits", + "services", + "deliveryServices", + "contactSources", + "contactChannels" ], "properties": { "articles": { @@ -1663,6 +3428,27 @@ "$ref": "#/components/schemas/Article" } }, + "contactChannels": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ContactChannel" + }, + "description": "Die zu `contact_sources` geh\u00f6renden Einzel-Kan\u00e4le (Telefon, Mobil,\nE-Mail, Web). Join per `source_id`." + }, + "contactSources": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ContactSource" + }, + "description": "Alle vom ERP gespiegelten Kontaktquellen aller Lieferungen dieser\nTour. Die App joint clientseitig per `delivery_id` und gruppiert\nnach `role` (Lieferadresse / Rechnungsadresse / Ansprechpartner /\nKundenstamm / Belegadresse)." + }, + "credits": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DeliveryCredit" + }, + "description": "Aktuelle Betrags-Gutschriften (j\u00fcngster Stand pro Lieferung), nur f\u00fcr\nLieferungen, deren letztes Ereignis `set` war. Join per `delivery_id`." + }, "customerContacts": { "type": "array", "items": { @@ -1681,6 +3467,13 @@ "$ref": "#/components/schemas/DeliveryWithItems" } }, + "deliveryServices": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DeliveryServiceValue" + }, + "description": "Pro-Lieferung gesetzte Service-Werte. Join per `delivery_id` +\n`service_id`." + }, "notes": { "type": "array", "items": { @@ -1688,6 +3481,13 @@ }, "description": "Alle Notizen aller Lieferungen dieser Tour, in einer Liste.\nDie App joint clientseitig per `delivery_id`. Reihenfolge:\npro Lieferung aufsteigend nach `created_at`." }, + "services": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Service" + }, + "description": "Aktive Service-Definitionen (Stammdaten) \u2014 die App rendert daraus\nPhase 4. Bewusst hier mitgeliefert, damit die Detailseite alles aus\ndem Tour-Aggregat hat." + }, "tour": { "$ref": "#/components/schemas/Tour" }, @@ -1755,6 +3555,83 @@ } } }, + "UpdateDeliveryNoteRequest": { + "type": "object", + "description": "Request f\u00fcr `PATCH /deliveries/{id}/notes/{note_id}`. Wie beim Create\nmuss mindestens eines von `text` / `image_attachment` inhaltlich gef\u00fcllt\nsein \u2014 gepr\u00fcft im Use Case.", + "properties": { + "imageAttachment": { + "type": [ + "string", + "null" + ], + "description": "Object-Storage-Key oder URL eines vorab hochgeladenen Bildes." + }, + "text": { + "type": [ + "string", + "null" + ] + } + } + }, + "UpdatePaymentMethodRequest": { + "type": "object", + "properties": { + "active": { + "type": [ + "boolean", + "null" + ], + "description": "Wenn gesetzt: aktiv/inaktiv. Inaktive Methoden bleiben f\u00fcr\nhistorische Lieferungen referenzierbar, tauchen aber im\nDefault-Listing nicht auf." + }, + "name": { + "type": [ + "string", + "null" + ], + "description": "Wenn gesetzt: neuer Anzeige-Name." + } + } + }, + "UpdateServiceRequest": { + "type": "object", + "description": "Teil-Update eines Service. `kind` ist bewusst **nicht** \u00e4nderbar \u2014 ein\nWechsel boolean\u2194numeric w\u00fcrde bestehende Pro-Lieferung-Werte ung\u00fcltig\nmachen (dann lieber deaktivieren + neu anlegen).", + "properties": { + "active": { + "type": [ + "boolean", + "null" + ] + }, + "maxValue": { + "type": [ + "integer", + "null" + ], + "format": "int32" + }, + "minValue": { + "type": [ + "integer", + "null" + ], + "format": "int32" + }, + "name": { + "type": [ + "string", + "null" + ] + }, + "sortOrder": { + "type": [ + "integer", + "null" + ], + "format": "int32" + } + } + }, "Warehouse": { "type": "object", "description": "Lager. ERP-Mirror; `code` ist die ERP-Lager-Nr (z. B. `\"0\"` f\u00fcr das\nStandardlager). Das `is_standard`-Flag ist der schnelle Filter f\u00fcr\ndie Beladen-Logik (\u201enur Standardlager-Artikel z\u00e4hlen f\u00fcr Fertig\").", @@ -1782,6 +3659,11 @@ } }, "securitySchemes": { + "admin_api_key": { + "type": "apiKey", + "in": "header", + "name": "X-Admin-Api-Key" + }, "bearer_auth": { "type": "http", "scheme": "bearer", @@ -1822,6 +3704,18 @@ { "name": "cars", "description": "Fahrzeug-Stammdaten pro Fahrer" + }, + { + "name": "payment-methods", + "description": "Zahlungsmethoden \u2014 globale Stammdaten" + }, + { + "name": "services", + "description": "Services / Lieferoptionen \u2014 globale Stammdaten" + }, + { + "name": "admin", + "description": "Betriebs-/Admin-Endpunkte (Maschinen-Zugang via X-Admin-Api-Key, kein JWT)" } ] } diff --git a/packages/holzleitner_api/.openapi-generator/FILES b/packages/holzleitner_api/.openapi-generator/FILES index c111d9d..43712ea 100644 --- a/packages/holzleitner_api/.openapi-generator/FILES +++ b/packages/holzleitner_api/.openapi-generator/FILES @@ -1,44 +1,74 @@ .gitignore -.openapi-generator-ignore README.md analysis_options.yaml doc/Account.md doc/AccountsApi.md doc/Address.md +doc/AdminApi.md doc/ApplyScansRequest.md doc/ApplyScansResponse.md doc/Article.md doc/AssignCarRequest.md +doc/AttachmentsApi.md doc/AuditAction.md doc/CancelDeliveryRequest.md doc/Car.md doc/CarResponse.md doc/CarsApi.md doc/CarsList.md +doc/CompleteDeliveryAcknowledgements.md +doc/ContactChannel.md +doc/ContactKind.md +doc/ContactRole.md +doc/ContactSource.md doc/CreateCarRequest.md doc/CreateDeliveryNoteRequest.md +doc/CreatePaymentMethodRequest.md +doc/CreateServiceRequest.md +doc/CreditAction.md doc/Customer.md doc/CustomerContact.md +doc/DeliveredBelegnummernResponse.md doc/DeliveriesApi.md doc/Delivery.md +doc/DeliveryCredit.md +doc/DeliveryCreditEventRequest.md +doc/DeliveryCreditResponse.md doc/DeliveryItem.md doc/DeliveryNote.md doc/DeliveryNoteResponse.md doc/DeliveryOrderEntry.md doc/DeliveryResponse.md +doc/DeliveryServiceResponse.md +doc/DeliveryServiceValue.md doc/DeliveryState.md doc/DeliveryWithItems.md doc/HealthApi.md doc/HoldDeliveryRequest.md +doc/ImportSummary.md +doc/MarkMailSentRequest.md +doc/MarkMailSentResponse.md +doc/PaymentMethod.md +doc/PaymentMethodResponse.md +doc/PaymentMethodsApi.md +doc/PaymentMethodsList.md doc/ScanEvent.md doc/ScanResult.md doc/ScanResultStatus.md doc/ScanState.md doc/ScanStatus.md doc/ScansApi.md +doc/Service.md +doc/ServiceKind.md +doc/ServiceResponse.md +doc/ServicesApi.md +doc/ServicesList.md doc/SetDeliveryOrderRequest.md doc/SetDeliveryOrderResponse.md +doc/SetDeliveryServiceRequest.md doc/SyncApi.md +doc/SyncContactChannel.md +doc/SyncContactSource.md doc/SyncDelivery.md doc/SyncDeliveryItem.md doc/SyncTourRequest.md @@ -49,14 +79,21 @@ doc/TourSummary.md doc/TourSummaryList.md doc/ToursApi.md doc/UpdateCarRequest.md +doc/UpdateDeliveryNoteRequest.md +doc/UpdatePaymentMethodRequest.md +doc/UpdateServiceRequest.md doc/Warehouse.md lib/holzleitner_api.dart lib/src/api.dart lib/src/api/accounts_api.dart +lib/src/api/admin_api.dart +lib/src/api/attachments_api.dart lib/src/api/cars_api.dart lib/src/api/deliveries_api.dart lib/src/api/health_api.dart +lib/src/api/payment_methods_api.dart lib/src/api/scans_api.dart +lib/src/api/services_api.dart lib/src/api/sync_api.dart lib/src/api/tours_api.dart lib/src/api_util.dart @@ -77,27 +114,54 @@ lib/src/model/cancel_delivery_request.dart lib/src/model/car.dart lib/src/model/car_response.dart lib/src/model/cars_list.dart +lib/src/model/complete_delivery_acknowledgements.dart +lib/src/model/contact_channel.dart +lib/src/model/contact_kind.dart +lib/src/model/contact_role.dart +lib/src/model/contact_source.dart lib/src/model/create_car_request.dart lib/src/model/create_delivery_note_request.dart +lib/src/model/create_payment_method_request.dart +lib/src/model/create_service_request.dart +lib/src/model/credit_action.dart lib/src/model/customer.dart lib/src/model/customer_contact.dart lib/src/model/date.dart +lib/src/model/delivered_belegnummern_response.dart lib/src/model/delivery.dart +lib/src/model/delivery_credit.dart +lib/src/model/delivery_credit_event_request.dart +lib/src/model/delivery_credit_response.dart lib/src/model/delivery_item.dart lib/src/model/delivery_note.dart lib/src/model/delivery_note_response.dart lib/src/model/delivery_order_entry.dart lib/src/model/delivery_response.dart +lib/src/model/delivery_service_response.dart +lib/src/model/delivery_service_value.dart lib/src/model/delivery_state.dart lib/src/model/delivery_with_items.dart lib/src/model/hold_delivery_request.dart +lib/src/model/import_summary.dart +lib/src/model/mark_mail_sent_request.dart +lib/src/model/mark_mail_sent_response.dart +lib/src/model/payment_method.dart +lib/src/model/payment_method_response.dart +lib/src/model/payment_methods_list.dart lib/src/model/scan_event.dart lib/src/model/scan_result.dart lib/src/model/scan_result_status.dart lib/src/model/scan_state.dart lib/src/model/scan_status.dart +lib/src/model/service.dart +lib/src/model/service_kind.dart +lib/src/model/service_response.dart +lib/src/model/services_list.dart lib/src/model/set_delivery_order_request.dart lib/src/model/set_delivery_order_response.dart +lib/src/model/set_delivery_service_request.dart +lib/src/model/sync_contact_channel.dart +lib/src/model/sync_contact_source.dart lib/src/model/sync_delivery.dart lib/src/model/sync_delivery_item.dart lib/src/model/sync_tour_request.dart @@ -107,54 +171,18 @@ lib/src/model/tour_details.dart lib/src/model/tour_summary.dart lib/src/model/tour_summary_list.dart lib/src/model/update_car_request.dart +lib/src/model/update_delivery_note_request.dart +lib/src/model/update_payment_method_request.dart +lib/src/model/update_service_request.dart lib/src/model/warehouse.dart lib/src/serializers.dart pubspec.yaml -test/account_test.dart -test/accounts_api_test.dart -test/address_test.dart -test/apply_scans_request_test.dart -test/apply_scans_response_test.dart -test/article_test.dart -test/assign_car_request_test.dart -test/audit_action_test.dart -test/cancel_delivery_request_test.dart -test/car_response_test.dart -test/car_test.dart -test/cars_api_test.dart -test/cars_list_test.dart -test/create_car_request_test.dart -test/create_delivery_note_request_test.dart -test/customer_contact_test.dart -test/customer_test.dart -test/deliveries_api_test.dart -test/delivery_item_test.dart -test/delivery_note_response_test.dart -test/delivery_note_test.dart -test/delivery_order_entry_test.dart -test/delivery_response_test.dart -test/delivery_state_test.dart -test/delivery_test.dart -test/delivery_with_items_test.dart -test/health_api_test.dart -test/hold_delivery_request_test.dart -test/scan_event_test.dart -test/scan_result_status_test.dart -test/scan_result_test.dart -test/scan_state_test.dart -test/scan_status_test.dart -test/scans_api_test.dart -test/set_delivery_order_request_test.dart -test/set_delivery_order_response_test.dart -test/sync_api_test.dart -test/sync_delivery_item_test.dart -test/sync_delivery_test.dart -test/sync_tour_request_test.dart -test/sync_tour_response_test.dart -test/tour_details_test.dart -test/tour_summary_list_test.dart -test/tour_summary_test.dart -test/tour_test.dart -test/tours_api_test.dart -test/update_car_request_test.dart -test/warehouse_test.dart +test/contact_channel_test.dart +test/contact_kind_test.dart +test/contact_role_test.dart +test/contact_source_test.dart +test/delivered_belegnummern_response_test.dart +test/mark_mail_sent_request_test.dart +test/mark_mail_sent_response_test.dart +test/sync_contact_channel_test.dart +test/sync_contact_source_test.dart diff --git a/packages/holzleitner_api/README.md b/packages/holzleitner_api/README.md index 76707f0..c14b6c3 100644 --- a/packages/holzleitner_api/README.md +++ b/packages/holzleitner_api/README.md @@ -66,17 +66,36 @@ All URIs are relative to *http://localhost* Class | Method | HTTP request | Description ------------ | ------------- | ------------- | ------------- [*AccountsApi*](doc/AccountsApi.md) | [**getAccount**](doc/AccountsApi.md#getaccount) | **GET** /accounts/{personalnummer} | Liest den Account zu einer Personalnummer. +[*AdminApi*](doc/AdminApi.md) | [**deliveredBelegnummern**](doc/AdminApi.md#deliveredbelegnummern) | **GET** /admin/delivered-belegnummern | Liefert die Belegnummern ausgelieferter (abgeschlossener) Lieferungen, **deren Liefermail noch nicht versendet wurde** (`mail_sent_at IS NULL`). „Ausgeliefert\" = es existiert ein Abschluss. Mit `day` (DD-MM-YYYY) nur Abschlüsse dieses Berliner Kalendertages; **ohne `day` alle offenen** (über alle Tage) — so bleiben Belege über Mitternacht nicht hängen. +[*AdminApi*](doc/AdminApi.md) | [**importErp**](doc/AdminApi.md#importerp) | **POST** /admin/import-erp | Stößt den ERP-Import für ein Datum an und liefert die Zusammenfassung. +[*AdminApi*](doc/AdminApi.md) | [**markMailSent**](doc/AdminApi.md#markmailsent) | **POST** /admin/mark-mail-sent | Markiert die Liefermails der angegebenen Belegnummern als **versendet** (`mail_sent_at = now()`, nur wo noch offen). Vom Mailclient aufzurufen, NACHDEM ERPframe die Mails erfolgreich verschickt hat — danach erscheinen die Belege nicht mehr in `GET /admin/delivered-belegnummern`. +[*AdminApi*](doc/AdminApi.md) | [**pushCompletion**](doc/AdminApi.md#pushcompletion) | **POST** /admin/push-completion | Stößt das ERP-Rückschreiben eines bereits lokal abgeschlossenen Lieferabschlusses erneut an (idempotenter Retry, falls der automatische Push beim Abschluss fehlschlug). +[*AttachmentsApi*](doc/AttachmentsApi.md) | [**getAttachment**](doc/AttachmentsApi.md#getattachment) | **GET** /attachments/{id} | Liefert ein gerendertes Vorschaubild des Attachments (Bytes), geladen aus DOCUframe. Auflösung/Format über Query-Parameter steuerbar (`?w=&h=&q=&ext=&page=`). [*CarsApi*](doc/CarsApi.md) | [**createMyCar**](doc/CarsApi.md#createmycar) | **POST** /me/cars | Legt ein neues Fahrzeug für den angemeldeten Fahrer an. [*CarsApi*](doc/CarsApi.md) | [**listMyCars**](doc/CarsApi.md#listmycars) | **GET** /me/cars | Listet die Fahrzeuge des angemeldeten Fahrers. [*CarsApi*](doc/CarsApi.md) | [**updateMyCar**](doc/CarsApi.md#updatemycar) | **PATCH** /me/cars/{car_id} | Aktualisiert ein Fahrzeug (Kennzeichen ändern / deaktivieren). +[*DeliveriesApi*](doc/DeliveriesApi.md) | [**applyCredit**](doc/DeliveriesApi.md#applycredit) | **POST** /deliveries/{delivery_id}/credit | Wendet ein Betrags-Gutschrift-Ereignis an (`set`/`remove`). Append-only, idempotent über `clientEventId`. Nur bei aktiver Lieferung; bei `set` sind Betrag (0 < x ≤ 150 €, 10-€-Schritte) und Grund Pflicht. Antwort: der aktuelle Gutschrift-Stand (`null`, wenn entfernt). [*DeliveriesApi*](doc/DeliveriesApi.md) | [**assignCar**](doc/DeliveriesApi.md#assigncar) | **PUT** /deliveries/{delivery_id}/assigned-car | Setzt das `assigned_car_id` einer Lieferung. `carId: null` löst die Zuordnung wieder. Der Use Case stellt sicher, dass das Fahrzeug zum angemeldeten Account gehört. [*DeliveriesApi*](doc/DeliveriesApi.md) | [**cancel**](doc/DeliveriesApi.md#cancel) | **POST** /deliveries/{delivery_id}/cancel | Setzt die Lieferung auf `canceled` — endgültig. Erlaubt aus `active` und `held`. [*DeliveriesApi*](doc/DeliveriesApi.md) | [**complete**](doc/DeliveriesApi.md#complete) | **POST** /deliveries/{delivery_id}/complete | Schließt die Lieferung ab — `state = completed`. Nur aus `active`. [*DeliveriesApi*](doc/DeliveriesApi.md) | [**createNote**](doc/DeliveriesApi.md#createnote) | **POST** /deliveries/{delivery_id}/notes | Legt eine neue Notiz an einer Lieferung an. Mindestens eines von `text` und `imageAttachment` muss inhaltlich gefüllt sein (Leerstrings werden serverseitig getrimmt und als leer behandelt). +[*DeliveriesApi*](doc/DeliveriesApi.md) | [**deleteNote**](doc/DeliveriesApi.md#deletenote) | **DELETE** /deliveries/{delivery_id}/notes/{note_id} | Löscht eine Notiz. Antwortet mit `204 No Content`. +[*DeliveriesApi*](doc/DeliveriesApi.md) | [**deleteServiceValue**](doc/DeliveriesApi.md#deleteservicevalue) | **DELETE** /deliveries/{delivery_id}/services/{service_id} | Entfernt den Service-Wert einer Lieferung (Service „nicht gesetzt\"). Nur bei aktiver Lieferung. Antwort `204`. [*DeliveriesApi*](doc/DeliveriesApi.md) | [**hold**](doc/DeliveriesApi.md#hold) | **POST** /deliveries/{delivery_id}/hold | Setzt die Lieferung auf `held`. Nur aus `active` zulässig. [*DeliveriesApi*](doc/DeliveriesApi.md) | [**resume**](doc/DeliveriesApi.md#resume) | **POST** /deliveries/{delivery_id}/resume | Setzt die Lieferung zurück auf `active`. Nur aus `held` zulässig. +[*DeliveriesApi*](doc/DeliveriesApi.md) | [**setService**](doc/DeliveriesApi.md#setservice) | **PUT** /deliveries/{delivery_id}/services/{service_id} | Setzt (Upsert) den Wert eines Service für eine Lieferung. Genau das zum Service-Typ passende Feld (`boolValue`/`numericValue`) muss gesetzt sein; numerische Werte werden gegen min/max geprüft. Nur bei aktiver Lieferung. +[*DeliveriesApi*](doc/DeliveriesApi.md) | [**updateNote**](doc/DeliveriesApi.md#updatenote) | **PATCH** /deliveries/{delivery_id}/notes/{note_id} | Ändert Text/Bild einer Notiz. Innerhalb des (geteilten) Accounts darf jeder Fahrer Notizen pflegen — kein Autor-Check. `delivery_id` ist Teil des Pfads (REST-Konsistenz), die Notiz wird über `note_id` adressiert. +[*DeliveriesApi*](doc/DeliveriesApi.md) | [**uploadNoteImage**](doc/DeliveriesApi.md#uploadnoteimage) | **POST** /deliveries/{delivery_id}/notes/image | Lädt ein Bild zu einer Lieferung hoch (multipart/form-data, Feld `file`) und legt dafür eine Bild-Notiz an. Das Bild geht in den DOCUframe-Dokumentenspeicher; gespeichert wird die zurückgelieferte Referenz (`~ObjectID`) als `image_attachment` der Notiz. [*HealthApi*](doc/HealthApi.md) | [**health**](doc/HealthApi.md#health) | **GET** /health | Health-Endpoint für Load-Balancer und Container-Probes. Bewusst kein Auth — eine `200 ok`-Antwort darf nicht von der Auth abhängen. +[*PaymentMethodsApi*](doc/PaymentMethodsApi.md) | [**createPaymentMethod**](doc/PaymentMethodsApi.md#createpaymentmethod) | **POST** /payment-methods | Legt eine neue Zahlungsmethode an. +[*PaymentMethodsApi*](doc/PaymentMethodsApi.md) | [**deletePaymentMethod**](doc/PaymentMethodsApi.md#deletepaymentmethod) | **DELETE** /payment-methods/{id} | Hartes Löschen. `409 Conflict`, wenn die Methode von einer Lieferung referenziert wird — der Admin soll dann den `active = false`-Pfad nutzen. +[*PaymentMethodsApi*](doc/PaymentMethodsApi.md) | [**listPaymentMethods**](doc/PaymentMethodsApi.md#listpaymentmethods) | **GET** /payment-methods | Listet die Zahlungsmethoden. +[*PaymentMethodsApi*](doc/PaymentMethodsApi.md) | [**updatePaymentMethod**](doc/PaymentMethodsApi.md#updatepaymentmethod) | **PATCH** /payment-methods/{id} | Patcht Anzeige-Name und/oder Aktiv-Flag. [*ScansApi*](doc/ScansApi.md) | [**applyScans**](doc/ScansApi.md#applyscans) | **POST** /scans | Wendet eine Liste von Scan-Events idempotent an. +[*ServicesApi*](doc/ServicesApi.md) | [**createService**](doc/ServicesApi.md#createservice) | **POST** /services | Legt einen neuen Service an. +[*ServicesApi*](doc/ServicesApi.md) | [**deleteService**](doc/ServicesApi.md#deleteservice) | **DELETE** /services/{id} | Hartes Löschen. `409 Conflict`, wenn der Service noch von einer Lieferung referenziert wird — dann stattdessen deaktivieren. +[*ServicesApi*](doc/ServicesApi.md) | [**listServices**](doc/ServicesApi.md#listservices) | **GET** /services | Listet die Services (sortiert nach `sortOrder`). +[*ServicesApi*](doc/ServicesApi.md) | [**updateService**](doc/ServicesApi.md#updateservice) | **PATCH** /services/{id} | Patcht Name/Grenzen/Aktiv-Flag/Sortierung. `kind` ist nicht änderbar. [*SyncApi*](doc/SyncApi.md) | [**syncTour**](doc/SyncApi.md#synctour) | **POST** /sync/tour | Sync-Endpoint für das ERP: legt eine Tagestour samt Lieferungen und Positionen idempotent an. Identität pro Tour `(driver_personalnummer, tour_date)`, pro Lieferung `(belegart_id, belegnummer)`. [*ToursApi*](doc/ToursApi.md) | [**getTour**](doc/ToursApi.md#gettour) | **GET** /tours/{tour_id} | Lädt eine Tour mit allen Lieferungen, Positionen und referenzierten Stammdaten — die App nutzt das als einzigen großen Read. [*ToursApi*](doc/ToursApi.md) | [**listMyToursToday**](doc/ToursApi.md#listmytourstoday) | **GET** /me/tours/today | Listet heutige Touren des angemeldeten Fahrers (Filter aus dem JWT). @@ -96,26 +115,53 @@ Class | Method | HTTP request | Description - [Car](doc/Car.md) - [CarResponse](doc/CarResponse.md) - [CarsList](doc/CarsList.md) + - [CompleteDeliveryAcknowledgements](doc/CompleteDeliveryAcknowledgements.md) + - [ContactChannel](doc/ContactChannel.md) + - [ContactKind](doc/ContactKind.md) + - [ContactRole](doc/ContactRole.md) + - [ContactSource](doc/ContactSource.md) - [CreateCarRequest](doc/CreateCarRequest.md) - [CreateDeliveryNoteRequest](doc/CreateDeliveryNoteRequest.md) + - [CreatePaymentMethodRequest](doc/CreatePaymentMethodRequest.md) + - [CreateServiceRequest](doc/CreateServiceRequest.md) + - [CreditAction](doc/CreditAction.md) - [Customer](doc/Customer.md) - [CustomerContact](doc/CustomerContact.md) + - [DeliveredBelegnummernResponse](doc/DeliveredBelegnummernResponse.md) - [Delivery](doc/Delivery.md) + - [DeliveryCredit](doc/DeliveryCredit.md) + - [DeliveryCreditEventRequest](doc/DeliveryCreditEventRequest.md) + - [DeliveryCreditResponse](doc/DeliveryCreditResponse.md) - [DeliveryItem](doc/DeliveryItem.md) - [DeliveryNote](doc/DeliveryNote.md) - [DeliveryNoteResponse](doc/DeliveryNoteResponse.md) - [DeliveryOrderEntry](doc/DeliveryOrderEntry.md) - [DeliveryResponse](doc/DeliveryResponse.md) + - [DeliveryServiceResponse](doc/DeliveryServiceResponse.md) + - [DeliveryServiceValue](doc/DeliveryServiceValue.md) - [DeliveryState](doc/DeliveryState.md) - [DeliveryWithItems](doc/DeliveryWithItems.md) - [HoldDeliveryRequest](doc/HoldDeliveryRequest.md) + - [ImportSummary](doc/ImportSummary.md) + - [MarkMailSentRequest](doc/MarkMailSentRequest.md) + - [MarkMailSentResponse](doc/MarkMailSentResponse.md) + - [PaymentMethod](doc/PaymentMethod.md) + - [PaymentMethodResponse](doc/PaymentMethodResponse.md) + - [PaymentMethodsList](doc/PaymentMethodsList.md) - [ScanEvent](doc/ScanEvent.md) - [ScanResult](doc/ScanResult.md) - [ScanResultStatus](doc/ScanResultStatus.md) - [ScanState](doc/ScanState.md) - [ScanStatus](doc/ScanStatus.md) + - [Service](doc/Service.md) + - [ServiceKind](doc/ServiceKind.md) + - [ServiceResponse](doc/ServiceResponse.md) + - [ServicesList](doc/ServicesList.md) - [SetDeliveryOrderRequest](doc/SetDeliveryOrderRequest.md) - [SetDeliveryOrderResponse](doc/SetDeliveryOrderResponse.md) + - [SetDeliveryServiceRequest](doc/SetDeliveryServiceRequest.md) + - [SyncContactChannel](doc/SyncContactChannel.md) + - [SyncContactSource](doc/SyncContactSource.md) - [SyncDelivery](doc/SyncDelivery.md) - [SyncDeliveryItem](doc/SyncDeliveryItem.md) - [SyncTourRequest](doc/SyncTourRequest.md) @@ -125,6 +171,9 @@ Class | Method | HTTP request | Description - [TourSummary](doc/TourSummary.md) - [TourSummaryList](doc/TourSummaryList.md) - [UpdateCarRequest](doc/UpdateCarRequest.md) + - [UpdateDeliveryNoteRequest](doc/UpdateDeliveryNoteRequest.md) + - [UpdatePaymentMethodRequest](doc/UpdatePaymentMethodRequest.md) + - [UpdateServiceRequest](doc/UpdateServiceRequest.md) - [Warehouse](doc/Warehouse.md) @@ -132,6 +181,12 @@ Class | Method | HTTP request | Description Authentication schemes defined for the API: +### admin_api_key + +- **Type**: API key +- **API key parameter name**: X-Admin-Api-Key +- **Location**: HTTP header + ### bearer_auth - **Type**: HTTP Bearer Token authentication (JWT) diff --git a/packages/holzleitner_api/doc/AdminApi.md b/packages/holzleitner_api/doc/AdminApi.md new file mode 100644 index 0000000..9dc0772 --- /dev/null +++ b/packages/holzleitner_api/doc/AdminApi.md @@ -0,0 +1,196 @@ +# holzleitner_api.api.AdminApi + +## Load the API package +```dart +import 'package:holzleitner_api/api.dart'; +``` + +All URIs are relative to *http://localhost* + +Method | HTTP request | Description +------------- | ------------- | ------------- +[**deliveredBelegnummern**](AdminApi.md#deliveredbelegnummern) | **GET** /admin/delivered-belegnummern | Liefert die Belegnummern ausgelieferter (abgeschlossener) Lieferungen, **deren Liefermail noch nicht versendet wurde** (`mail_sent_at IS NULL`). „Ausgeliefert\" = es existiert ein Abschluss. Mit `day` (DD-MM-YYYY) nur Abschlüsse dieses Berliner Kalendertages; **ohne `day` alle offenen** (über alle Tage) — so bleiben Belege über Mitternacht nicht hängen. +[**importErp**](AdminApi.md#importerp) | **POST** /admin/import-erp | Stößt den ERP-Import für ein Datum an und liefert die Zusammenfassung. +[**markMailSent**](AdminApi.md#markmailsent) | **POST** /admin/mark-mail-sent | Markiert die Liefermails der angegebenen Belegnummern als **versendet** (`mail_sent_at = now()`, nur wo noch offen). Vom Mailclient aufzurufen, NACHDEM ERPframe die Mails erfolgreich verschickt hat — danach erscheinen die Belege nicht mehr in `GET /admin/delivered-belegnummern`. +[**pushCompletion**](AdminApi.md#pushcompletion) | **POST** /admin/push-completion | Stößt das ERP-Rückschreiben eines bereits lokal abgeschlossenen Lieferabschlusses erneut an (idempotenter Retry, falls der automatische Push beim Abschluss fehlschlug). + + +# **deliveredBelegnummern** +> DeliveredBelegnummernResponse deliveredBelegnummern(day) + +Liefert die Belegnummern ausgelieferter (abgeschlossener) Lieferungen, **deren Liefermail noch nicht versendet wurde** (`mail_sent_at IS NULL`). „Ausgeliefert\" = es existiert ein Abschluss. Mit `day` (DD-MM-YYYY) nur Abschlüsse dieses Berliner Kalendertages; **ohne `day` alle offenen** (über alle Tage) — so bleiben Belege über Mitternacht nicht hängen. + +### Example +```dart +import 'package:holzleitner_api/api.dart'; +// TODO Configure API key authorization: admin_api_key +//defaultApiClient.getAuthentication('admin_api_key').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('admin_api_key').apiKeyPrefix = 'Bearer'; + +final api = HolzleitnerApi().getAdminApi(); +final String day = day_example; // String | Tag DD-MM-YYYY; ohne Angabe ALLE offenen Belege + +try { + final response = api.deliveredBelegnummern(day); + print(response); +} catch on DioException (e) { + print('Exception when calling AdminApi->deliveredBelegnummern: $e\n'); +} +``` + +### Parameters + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- + **day** | **String**| Tag DD-MM-YYYY; ohne Angabe ALLE offenen Belege | [optional] + +### Return type + +[**DeliveredBelegnummernResponse**](DeliveredBelegnummernResponse.md) + +### Authorization + +[admin_api_key](../README.md#admin_api_key) + +### HTTP request headers + + - **Content-Type**: Not defined + - **Accept**: application/json + +[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) + +# **importErp** +> ImportSummary importErp(date) + +Stößt den ERP-Import für ein Datum an und liefert die Zusammenfassung. + +### Example +```dart +import 'package:holzleitner_api/api.dart'; +// TODO Configure API key authorization: admin_api_key +//defaultApiClient.getAuthentication('admin_api_key').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('admin_api_key').apiKeyPrefix = 'Bearer'; + +final api = HolzleitnerApi().getAdminApi(); +final String date = date_example; // String | Ziel-Tourdatum YYYY-MM-DD (Default: heute) + +try { + final response = api.importErp(date); + print(response); +} catch on DioException (e) { + print('Exception when calling AdminApi->importErp: $e\n'); +} +``` + +### Parameters + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- + **date** | **String**| Ziel-Tourdatum YYYY-MM-DD (Default: heute) | [optional] + +### Return type + +[**ImportSummary**](ImportSummary.md) + +### Authorization + +[admin_api_key](../README.md#admin_api_key) + +### HTTP request headers + + - **Content-Type**: Not defined + - **Accept**: application/json + +[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) + +# **markMailSent** +> MarkMailSentResponse markMailSent(markMailSentRequest) + +Markiert die Liefermails der angegebenen Belegnummern als **versendet** (`mail_sent_at = now()`, nur wo noch offen). Vom Mailclient aufzurufen, NACHDEM ERPframe die Mails erfolgreich verschickt hat — danach erscheinen die Belege nicht mehr in `GET /admin/delivered-belegnummern`. + +### Example +```dart +import 'package:holzleitner_api/api.dart'; +// TODO Configure API key authorization: admin_api_key +//defaultApiClient.getAuthentication('admin_api_key').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('admin_api_key').apiKeyPrefix = 'Bearer'; + +final api = HolzleitnerApi().getAdminApi(); +final MarkMailSentRequest markMailSentRequest = ; // MarkMailSentRequest | + +try { + final response = api.markMailSent(markMailSentRequest); + print(response); +} catch on DioException (e) { + print('Exception when calling AdminApi->markMailSent: $e\n'); +} +``` + +### Parameters + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- + **markMailSentRequest** | [**MarkMailSentRequest**](MarkMailSentRequest.md)| | + +### Return type + +[**MarkMailSentResponse**](MarkMailSentResponse.md) + +### Authorization + +[admin_api_key](../README.md#admin_api_key) + +### HTTP request headers + + - **Content-Type**: application/json + - **Accept**: application/json + +[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) + +# **pushCompletion** +> pushCompletion(deliveryId) + +Stößt das ERP-Rückschreiben eines bereits lokal abgeschlossenen Lieferabschlusses erneut an (idempotenter Retry, falls der automatische Push beim Abschluss fehlschlug). + +### Example +```dart +import 'package:holzleitner_api/api.dart'; +// TODO Configure API key authorization: admin_api_key +//defaultApiClient.getAuthentication('admin_api_key').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('admin_api_key').apiKeyPrefix = 'Bearer'; + +final api = HolzleitnerApi().getAdminApi(); +final String deliveryId = deliveryId_example; // String | UUID der abgeschlossenen Lieferung + +try { + api.pushCompletion(deliveryId); +} catch on DioException (e) { + print('Exception when calling AdminApi->pushCompletion: $e\n'); +} +``` + +### Parameters + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- + **deliveryId** | **String**| UUID der abgeschlossenen Lieferung | + +### Return type + +void (empty response body) + +### Authorization + +[admin_api_key](../README.md#admin_api_key) + +### HTTP request headers + + - **Content-Type**: Not defined + - **Accept**: Not defined + +[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) + diff --git a/packages/holzleitner_api/doc/AttachmentsApi.md b/packages/holzleitner_api/doc/AttachmentsApi.md new file mode 100644 index 0000000..1bbc786 --- /dev/null +++ b/packages/holzleitner_api/doc/AttachmentsApi.md @@ -0,0 +1,64 @@ +# holzleitner_api.api.AttachmentsApi + +## Load the API package +```dart +import 'package:holzleitner_api/api.dart'; +``` + +All URIs are relative to *http://localhost* + +Method | HTTP request | Description +------------- | ------------- | ------------- +[**getAttachment**](AttachmentsApi.md#getattachment) | **GET** /attachments/{id} | Liefert ein gerendertes Vorschaubild des Attachments (Bytes), geladen aus DOCUframe. Auflösung/Format über Query-Parameter steuerbar (`?w=&h=&q=&ext=&page=`). + + +# **getAttachment** +> getAttachment(id, w, h, q, ext, page) + +Liefert ein gerendertes Vorschaubild des Attachments (Bytes), geladen aus DOCUframe. Auflösung/Format über Query-Parameter steuerbar (`?w=&h=&q=&ext=&page=`). + +### Example +```dart +import 'package:holzleitner_api/api.dart'; + +final api = HolzleitnerApi().getAttachmentsApi(); +final String id = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | Attachment-Id (unsere UUID) +final int w = 56; // int | Breite in Pixeln (Default 1024) +final int h = 56; // int | Höhe in Pixeln (Default 1024) +final int q = 56; // int | Qualität 0–100 (Default 85) +final String ext = ext_example; // String | png|jpeg|jpg|webp|tiff (Default jpeg) +final String page = page_example; // String | Seitennummer (Default 1) + +try { + api.getAttachment(id, w, h, q, ext, page); +} catch on DioException (e) { + print('Exception when calling AttachmentsApi->getAttachment: $e\n'); +} +``` + +### Parameters + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- + **id** | **String**| Attachment-Id (unsere UUID) | + **w** | **int**| Breite in Pixeln (Default 1024) | [optional] + **h** | **int**| Höhe in Pixeln (Default 1024) | [optional] + **q** | **int**| Qualität 0–100 (Default 85) | [optional] + **ext** | **String**| png|jpeg|jpg|webp|tiff (Default jpeg) | [optional] + **page** | **String**| Seitennummer (Default 1) | [optional] + +### Return type + +void (empty response body) + +### Authorization + +[bearer_auth](../README.md#bearer_auth) + +### HTTP request headers + + - **Content-Type**: Not defined + - **Accept**: image/jpeg + +[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) + diff --git a/packages/holzleitner_api/doc/CompleteDeliveryAcknowledgements.md b/packages/holzleitner_api/doc/CompleteDeliveryAcknowledgements.md new file mode 100644 index 0000000..62c6def --- /dev/null +++ b/packages/holzleitner_api/doc/CompleteDeliveryAcknowledgements.md @@ -0,0 +1,20 @@ +# holzleitner_api.model.CompleteDeliveryAcknowledgements + +## Load the model package +```dart +import 'package:holzleitner_api/api.dart'; +``` + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**acknowledgedNoteIds** | **BuiltList<String>** | Notiz-IDs, die zum Abschlusszeitpunkt sichtbar waren und mit-bestätigt wurden (Audit-Robustheit). | [optional] +**authorCarId** | **String** | Fahrzeug des Akteurs (Audit-Spur). Muss zum Account gehören. | [optional] +**notesAcknowledged** | **bool** | „Anmerkungen zur Lieferung zur Kenntnis genommen.\" — Pflicht nur, wenn Notizen existieren (das prüft der Server). | [optional] +**paymentCollected** | **bool** | Inkasso-Bestätigung des Fahrers: „der offene Betrag wurde erhalten (bar) bzw. über das EC-Gerät abgerechnet.\" Pflicht nur, wenn beim Abschluss ein offener Betrag > 0 besteht UND die Methode ein Vor-Ort- Inkasso ist (Bar/EC) — das prüft der Server. Der kassierte Betrag wird server-seitig autoritativ berechnet (nicht vom Client übernommen). | [optional] +**paymentMethodId** | **String** | Optionale Zahlungsmethode, die der Fahrer beim Abschluss gewählt hat. `None` = die am Beleg hinterlegte Methode bleibt. Falls gesetzt, muss sie existieren **und** aktiv sein (vom Server geprüft). | [optional] +**receiptConfirmed** | **bool** | „Ware im ordnungsgemäßen Zustand erhalten / Aufbau korrekt.\" — Pflicht. | + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/packages/holzleitner_api/doc/ContactChannel.md b/packages/holzleitner_api/doc/ContactChannel.md new file mode 100644 index 0000000..4a5e166 --- /dev/null +++ b/packages/holzleitner_api/doc/ContactChannel.md @@ -0,0 +1,19 @@ +# holzleitner_api.model.ContactChannel + +## Load the model package +```dart +import 'package:holzleitner_api/api.dart'; +``` + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**id** | **String** | | +**kind** | [**ContactKind**](ContactKind.md) | | +**position** | **int** | | +**sourceId** | **String** | | +**value** | **String** | | + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/packages/holzleitner_api/doc/ContactKind.md b/packages/holzleitner_api/doc/ContactKind.md new file mode 100644 index 0000000..413f573 --- /dev/null +++ b/packages/holzleitner_api/doc/ContactKind.md @@ -0,0 +1,14 @@ +# holzleitner_api.model.ContactKind + +## Load the model package +```dart +import 'package:holzleitner_api/api.dart'; +``` + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/packages/holzleitner_api/doc/ContactRole.md b/packages/holzleitner_api/doc/ContactRole.md new file mode 100644 index 0000000..bbbc5b4 --- /dev/null +++ b/packages/holzleitner_api/doc/ContactRole.md @@ -0,0 +1,14 @@ +# holzleitner_api.model.ContactRole + +## Load the model package +```dart +import 'package:holzleitner_api/api.dart'; +``` + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/packages/holzleitner_api/doc/ContactSource.md b/packages/holzleitner_api/doc/ContactSource.md new file mode 100644 index 0000000..a3b7284 --- /dev/null +++ b/packages/holzleitner_api/doc/ContactSource.md @@ -0,0 +1,24 @@ +# holzleitner_api.model.ContactSource + +## Load the model package +```dart +import 'package:holzleitner_api/api.dart'; +``` + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**abteilung** | **String** | | [optional] +**anrede** | **String** | | [optional] +**deliveryId** | **String** | | +**funktion** | **String** | | [optional] +**id** | **String** | | +**name1** | **String** | | [optional] +**name2** | **String** | | [optional] +**name3** | **String** | | [optional] +**role** | [**ContactRole**](ContactRole.md) | | +**titel** | **String** | | [optional] + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/packages/holzleitner_api/doc/CreateDeliveryNoteRequest.md b/packages/holzleitner_api/doc/CreateDeliveryNoteRequest.md index 013dfbd..9d7b4c8 100644 --- a/packages/holzleitner_api/doc/CreateDeliveryNoteRequest.md +++ b/packages/holzleitner_api/doc/CreateDeliveryNoteRequest.md @@ -9,7 +9,9 @@ import 'package:holzleitner_api/api.dart'; Name | Type | Description | Notes ------------ | ------------- | ------------- | ------------- **authorCarId** | **String** | Fahrzeug, das die Notiz erzeugt hat. Muss zum angemeldeten Account gehören. `None` ist erlaubt. | [optional] +**creditDeliveryItemId** | **String** | Optionaler Gutschrift-Bezug: die Belegzeile, für die diese Notiz als Gutschrift-Grund angelegt wird. Ermöglicht das gezielte Löschen beim Unremove. `None` für normale Notizen. | [optional] **imageAttachment** | **String** | Object-Storage-Key oder URL eines vorab hochgeladenen Bildes. | [optional] +**isAmountCreditNote** | **bool** | `true` markiert die Notiz als Grund einer Betrags-Gutschrift (Lieferungs-Ebene). Default `false`. | [optional] **text** | **String** | | [optional] [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) diff --git a/packages/holzleitner_api/doc/CreatePaymentMethodRequest.md b/packages/holzleitner_api/doc/CreatePaymentMethodRequest.md new file mode 100644 index 0000000..af5e064 --- /dev/null +++ b/packages/holzleitner_api/doc/CreatePaymentMethodRequest.md @@ -0,0 +1,16 @@ +# holzleitner_api.model.CreatePaymentMethodRequest + +## Load the model package +```dart +import 'package:holzleitner_api/api.dart'; +``` + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**code** | **String** | Eindeutiger Programm-Identifier (z. B. `\"paypal\"`, `\"klarna\"`). | +**name** | **String** | Anzeige-Name in der UI. | + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/packages/holzleitner_api/doc/CreateServiceRequest.md b/packages/holzleitner_api/doc/CreateServiceRequest.md new file mode 100644 index 0000000..a0810e6 --- /dev/null +++ b/packages/holzleitner_api/doc/CreateServiceRequest.md @@ -0,0 +1,20 @@ +# holzleitner_api.model.CreateServiceRequest + +## Load the model package +```dart +import 'package:holzleitner_api/api.dart'; +``` + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**key** | **String** | Eindeutiger Programm-Identifier (z. B. `\"podium_setup\"`). | +**kind** | [**ServiceKind**](ServiceKind.md) | | +**maxValue** | **int** | | [optional] +**minValue** | **int** | Nur bei `Numeric` sinnvoll. | [optional] +**name** | **String** | | +**sortOrder** | **int** | | [optional] + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/packages/holzleitner_api/doc/CreditAction.md b/packages/holzleitner_api/doc/CreditAction.md new file mode 100644 index 0000000..29a68d4 --- /dev/null +++ b/packages/holzleitner_api/doc/CreditAction.md @@ -0,0 +1,14 @@ +# holzleitner_api.model.CreditAction + +## Load the model package +```dart +import 'package:holzleitner_api/api.dart'; +``` + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/packages/holzleitner_api/doc/DeliveredBelegnummernResponse.md b/packages/holzleitner_api/doc/DeliveredBelegnummernResponse.md new file mode 100644 index 0000000..134c6f2 --- /dev/null +++ b/packages/holzleitner_api/doc/DeliveredBelegnummernResponse.md @@ -0,0 +1,17 @@ +# holzleitner_api.model.DeliveredBelegnummernResponse + +## Load the model package +```dart +import 'package:holzleitner_api/api.dart'; +``` + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**belegnummern** | **BuiltList<String>** | Belegnummern aller **ausgelieferten** (abgeschlossenen) Lieferungen, deren Liefermail noch **nicht versendet** wurde, aufsteigend nach Abschluss-Zeitpunkt. | +**count** | **int** | Anzahl der offenen (noch nicht versendeten) Belege. | +**day** | **String** | Tag, nach dem gefiltert wurde (ISO `YYYY-MM-DD`), oder `\"all\"` wenn kein `day` angegeben war. | + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/packages/holzleitner_api/doc/DeliveriesApi.md b/packages/holzleitner_api/doc/DeliveriesApi.md index 50fcd12..4f5a3c3 100644 --- a/packages/holzleitner_api/doc/DeliveriesApi.md +++ b/packages/holzleitner_api/doc/DeliveriesApi.md @@ -9,14 +9,63 @@ All URIs are relative to *http://localhost* Method | HTTP request | Description ------------- | ------------- | ------------- +[**applyCredit**](DeliveriesApi.md#applycredit) | **POST** /deliveries/{delivery_id}/credit | Wendet ein Betrags-Gutschrift-Ereignis an (`set`/`remove`). Append-only, idempotent über `clientEventId`. Nur bei aktiver Lieferung; bei `set` sind Betrag (0 < x ≤ 150 €, 10-€-Schritte) und Grund Pflicht. Antwort: der aktuelle Gutschrift-Stand (`null`, wenn entfernt). [**assignCar**](DeliveriesApi.md#assigncar) | **PUT** /deliveries/{delivery_id}/assigned-car | Setzt das `assigned_car_id` einer Lieferung. `carId: null` löst die Zuordnung wieder. Der Use Case stellt sicher, dass das Fahrzeug zum angemeldeten Account gehört. [**cancel**](DeliveriesApi.md#cancel) | **POST** /deliveries/{delivery_id}/cancel | Setzt die Lieferung auf `canceled` — endgültig. Erlaubt aus `active` und `held`. [**complete**](DeliveriesApi.md#complete) | **POST** /deliveries/{delivery_id}/complete | Schließt die Lieferung ab — `state = completed`. Nur aus `active`. [**createNote**](DeliveriesApi.md#createnote) | **POST** /deliveries/{delivery_id}/notes | Legt eine neue Notiz an einer Lieferung an. Mindestens eines von `text` und `imageAttachment` muss inhaltlich gefüllt sein (Leerstrings werden serverseitig getrimmt und als leer behandelt). +[**deleteNote**](DeliveriesApi.md#deletenote) | **DELETE** /deliveries/{delivery_id}/notes/{note_id} | Löscht eine Notiz. Antwortet mit `204 No Content`. +[**deleteServiceValue**](DeliveriesApi.md#deleteservicevalue) | **DELETE** /deliveries/{delivery_id}/services/{service_id} | Entfernt den Service-Wert einer Lieferung (Service „nicht gesetzt\"). Nur bei aktiver Lieferung. Antwort `204`. [**hold**](DeliveriesApi.md#hold) | **POST** /deliveries/{delivery_id}/hold | Setzt die Lieferung auf `held`. Nur aus `active` zulässig. [**resume**](DeliveriesApi.md#resume) | **POST** /deliveries/{delivery_id}/resume | Setzt die Lieferung zurück auf `active`. Nur aus `held` zulässig. +[**setService**](DeliveriesApi.md#setservice) | **PUT** /deliveries/{delivery_id}/services/{service_id} | Setzt (Upsert) den Wert eines Service für eine Lieferung. Genau das zum Service-Typ passende Feld (`boolValue`/`numericValue`) muss gesetzt sein; numerische Werte werden gegen min/max geprüft. Nur bei aktiver Lieferung. +[**updateNote**](DeliveriesApi.md#updatenote) | **PATCH** /deliveries/{delivery_id}/notes/{note_id} | Ändert Text/Bild einer Notiz. Innerhalb des (geteilten) Accounts darf jeder Fahrer Notizen pflegen — kein Autor-Check. `delivery_id` ist Teil des Pfads (REST-Konsistenz), die Notiz wird über `note_id` adressiert. +[**uploadNoteImage**](DeliveriesApi.md#uploadnoteimage) | **POST** /deliveries/{delivery_id}/notes/image | Lädt ein Bild zu einer Lieferung hoch (multipart/form-data, Feld `file`) und legt dafür eine Bild-Notiz an. Das Bild geht in den DOCUframe-Dokumentenspeicher; gespeichert wird die zurückgelieferte Referenz (`~ObjectID`) als `image_attachment` der Notiz. +# **applyCredit** +> DeliveryCreditResponse applyCredit(deliveryId, deliveryCreditEventRequest) + +Wendet ein Betrags-Gutschrift-Ereignis an (`set`/`remove`). Append-only, idempotent über `clientEventId`. Nur bei aktiver Lieferung; bei `set` sind Betrag (0 < x ≤ 150 €, 10-€-Schritte) und Grund Pflicht. Antwort: der aktuelle Gutschrift-Stand (`null`, wenn entfernt). + +### Example +```dart +import 'package:holzleitner_api/api.dart'; + +final api = HolzleitnerApi().getDeliveriesApi(); +final String deliveryId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | +final DeliveryCreditEventRequest deliveryCreditEventRequest = ; // DeliveryCreditEventRequest | + +try { + final response = api.applyCredit(deliveryId, deliveryCreditEventRequest); + print(response); +} catch on DioException (e) { + print('Exception when calling DeliveriesApi->applyCredit: $e\n'); +} +``` + +### Parameters + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- + **deliveryId** | **String**| | + **deliveryCreditEventRequest** | [**DeliveryCreditEventRequest**](DeliveryCreditEventRequest.md)| | + +### Return type + +[**DeliveryCreditResponse**](DeliveryCreditResponse.md) + +### Authorization + +[bearer_auth](../README.md#bearer_auth) + +### HTTP request headers + + - **Content-Type**: application/json + - **Accept**: application/json + +[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) + # **assignCar** > DeliveryResponse assignCar(deliveryId, assignCarRequest) @@ -108,6 +157,8 @@ Name | Type | Description | Notes Schließt die Lieferung ab — `state = completed`. Nur aus `active`. +`multipart/form-data` mit drei Feldern: * `customer_signature` — PNG der Kunden-Unterschrift (Pflicht) * `driver_signature` — PNG der Fahrer-Unterschrift (Pflicht) * `acknowledgements` — JSON (`CompleteDeliveryAcknowledgements`): `receiptConfirmed` (Pflicht true), `notesAcknowledged`, `acknowledgedNoteIds`, `authorCarId`. Atomar: Signaturen werden lokal gespeichert, die Abschluss-Zeile geschrieben und der Status auf `completed` gesetzt — alles oder nichts. Gates: Lieferung aktiv, alle scanbaren Positionen fertig, Notizen bestätigt (falls vorhanden). + ### Example ```dart import 'package:holzleitner_api/api.dart'; @@ -139,7 +190,7 @@ Name | Type | Description | Notes ### HTTP request headers - - **Content-Type**: Not defined + - **Content-Type**: multipart/form-data - **Accept**: application/json [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) @@ -187,6 +238,90 @@ Name | Type | Description | Notes [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) +# **deleteNote** +> deleteNote(deliveryId, noteId) + +Löscht eine Notiz. Antwortet mit `204 No Content`. + +### Example +```dart +import 'package:holzleitner_api/api.dart'; + +final api = HolzleitnerApi().getDeliveriesApi(); +final String deliveryId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | +final String noteId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | + +try { + api.deleteNote(deliveryId, noteId); +} catch on DioException (e) { + print('Exception when calling DeliveriesApi->deleteNote: $e\n'); +} +``` + +### Parameters + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- + **deliveryId** | **String**| | + **noteId** | **String**| | + +### Return type + +void (empty response body) + +### Authorization + +[bearer_auth](../README.md#bearer_auth) + +### HTTP request headers + + - **Content-Type**: Not defined + - **Accept**: Not defined + +[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) + +# **deleteServiceValue** +> deleteServiceValue(deliveryId, serviceId) + +Entfernt den Service-Wert einer Lieferung (Service „nicht gesetzt\"). Nur bei aktiver Lieferung. Antwort `204`. + +### Example +```dart +import 'package:holzleitner_api/api.dart'; + +final api = HolzleitnerApi().getDeliveriesApi(); +final String deliveryId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | +final String serviceId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | + +try { + api.deleteServiceValue(deliveryId, serviceId); +} catch on DioException (e) { + print('Exception when calling DeliveriesApi->deleteServiceValue: $e\n'); +} +``` + +### Parameters + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- + **deliveryId** | **String**| | + **serviceId** | **String**| | + +### Return type + +void (empty response body) + +### Authorization + +[bearer_auth](../README.md#bearer_auth) + +### HTTP request headers + + - **Content-Type**: Not defined + - **Accept**: Not defined + +[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) + # **hold** > DeliveryResponse hold(deliveryId, holdDeliveryRequest) @@ -271,3 +406,134 @@ Name | Type | Description | Notes [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) +# **setService** +> DeliveryServiceResponse setService(deliveryId, serviceId, setDeliveryServiceRequest) + +Setzt (Upsert) den Wert eines Service für eine Lieferung. Genau das zum Service-Typ passende Feld (`boolValue`/`numericValue`) muss gesetzt sein; numerische Werte werden gegen min/max geprüft. Nur bei aktiver Lieferung. + +### Example +```dart +import 'package:holzleitner_api/api.dart'; + +final api = HolzleitnerApi().getDeliveriesApi(); +final String deliveryId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | +final String serviceId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | +final SetDeliveryServiceRequest setDeliveryServiceRequest = ; // SetDeliveryServiceRequest | + +try { + final response = api.setService(deliveryId, serviceId, setDeliveryServiceRequest); + print(response); +} catch on DioException (e) { + print('Exception when calling DeliveriesApi->setService: $e\n'); +} +``` + +### Parameters + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- + **deliveryId** | **String**| | + **serviceId** | **String**| | + **setDeliveryServiceRequest** | [**SetDeliveryServiceRequest**](SetDeliveryServiceRequest.md)| | + +### Return type + +[**DeliveryServiceResponse**](DeliveryServiceResponse.md) + +### Authorization + +[bearer_auth](../README.md#bearer_auth) + +### HTTP request headers + + - **Content-Type**: application/json + - **Accept**: application/json + +[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) + +# **updateNote** +> DeliveryNoteResponse updateNote(deliveryId, noteId, updateDeliveryNoteRequest) + +Ändert Text/Bild einer Notiz. Innerhalb des (geteilten) Accounts darf jeder Fahrer Notizen pflegen — kein Autor-Check. `delivery_id` ist Teil des Pfads (REST-Konsistenz), die Notiz wird über `note_id` adressiert. + +### Example +```dart +import 'package:holzleitner_api/api.dart'; + +final api = HolzleitnerApi().getDeliveriesApi(); +final String deliveryId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | +final String noteId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | +final UpdateDeliveryNoteRequest updateDeliveryNoteRequest = ; // UpdateDeliveryNoteRequest | + +try { + final response = api.updateNote(deliveryId, noteId, updateDeliveryNoteRequest); + print(response); +} catch on DioException (e) { + print('Exception when calling DeliveriesApi->updateNote: $e\n'); +} +``` + +### Parameters + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- + **deliveryId** | **String**| | + **noteId** | **String**| | + **updateDeliveryNoteRequest** | [**UpdateDeliveryNoteRequest**](UpdateDeliveryNoteRequest.md)| | + +### Return type + +[**DeliveryNoteResponse**](DeliveryNoteResponse.md) + +### Authorization + +[bearer_auth](../README.md#bearer_auth) + +### HTTP request headers + + - **Content-Type**: application/json + - **Accept**: application/json + +[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) + +# **uploadNoteImage** +> DeliveryNoteResponse uploadNoteImage(deliveryId) + +Lädt ein Bild zu einer Lieferung hoch (multipart/form-data, Feld `file`) und legt dafür eine Bild-Notiz an. Das Bild geht in den DOCUframe-Dokumentenspeicher; gespeichert wird die zurückgelieferte Referenz (`~ObjectID`) als `image_attachment` der Notiz. + +### Example +```dart +import 'package:holzleitner_api/api.dart'; + +final api = HolzleitnerApi().getDeliveriesApi(); +final String deliveryId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | + +try { + final response = api.uploadNoteImage(deliveryId); + print(response); +} catch on DioException (e) { + print('Exception when calling DeliveriesApi->uploadNoteImage: $e\n'); +} +``` + +### Parameters + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- + **deliveryId** | **String**| | + +### Return type + +[**DeliveryNoteResponse**](DeliveryNoteResponse.md) + +### Authorization + +[bearer_auth](../README.md#bearer_auth) + +### HTTP request headers + + - **Content-Type**: multipart/form-data + - **Accept**: application/json + +[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) + diff --git a/packages/holzleitner_api/doc/Delivery.md b/packages/holzleitner_api/doc/Delivery.md index 1e72b26..6f6a7ff 100644 --- a/packages/holzleitner_api/doc/Delivery.md +++ b/packages/holzleitner_api/doc/Delivery.md @@ -16,6 +16,8 @@ Name | Type | Description | Notes **erpBelegartId** | **int** | ERP-Beleg-Bezug: business-stabiles Paar `(Belegart, Belegnummer)`. Überlebt den Belegkopf-Archivübergang. | **erpBelegnummer** | **String** | | **id** | **String** | | +**paymentMethodId** | **String** | Für den Restbetrag gewählte Zahlungsart — FK auf `payment_methods`. Vom Kunden bei Bestellung festgelegt, der Fahrer übernimmt nur die Abwicklung. Aktiv-Flag und Anzeige-Name werden über die Stammdaten-Tabelle aufgelöst, nicht hier embeddet. | +**prepaidAmount** | **double** | Bei Bestellung schon bezahlter Betrag in EUR. `0.0` wenn der Kunde alles bei Lieferung zahlt. Wird vom ERP-Sync gefüllt. | **specialAgreements** | **String** | Sondervereinbarungen (z. B. „Türklingel defekt, hintenrum klopfen\"). | [optional] **state** | [**DeliveryState**](DeliveryState.md) | | **stateReason** | **String** | Begründung bei `state == Held` oder `state == Canceled`. Beim Resume / Complete wieder `None`. | [optional] diff --git a/packages/holzleitner_api/doc/DeliveryCredit.md b/packages/holzleitner_api/doc/DeliveryCredit.md new file mode 100644 index 0000000..1945667 --- /dev/null +++ b/packages/holzleitner_api/doc/DeliveryCredit.md @@ -0,0 +1,17 @@ +# holzleitner_api.model.DeliveryCredit + +## Load the model package +```dart +import 'package:holzleitner_api/api.dart'; +``` + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**amountCents** | **int** | Gutschrift-Betrag in Cent (> 0, ≤ 15000). | +**deliveryId** | **String** | | +**reason** | **String** | | + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/packages/holzleitner_api/doc/DeliveryCreditEventRequest.md b/packages/holzleitner_api/doc/DeliveryCreditEventRequest.md new file mode 100644 index 0000000..bdd9a54 --- /dev/null +++ b/packages/holzleitner_api/doc/DeliveryCreditEventRequest.md @@ -0,0 +1,19 @@ +# holzleitner_api.model.DeliveryCreditEventRequest + +## Load the model package +```dart +import 'package:holzleitner_api/api.dart'; +``` + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**action** | [**CreditAction**](CreditAction.md) | | +**amountCents** | **int** | Bei `Set` Pflicht: Betrag in Cent (> 0, ≤ 15000, Vielfaches von 1000). | [optional] +**authorCarId** | **String** | Fahrzeug des Akteurs (Audit-Spur). Muss zum Account gehören. | [optional] +**clientEventId** | **String** | Idempotenz-Schlüssel — pro erzeugtem Ereignis genau einmal vergeben. Ein Retry mit derselben Id wendet nichts erneut an. | +**reason** | **String** | Bei `Set` Pflicht: Begründung. | [optional] + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/packages/holzleitner_api/doc/DeliveryCreditResponse.md b/packages/holzleitner_api/doc/DeliveryCreditResponse.md new file mode 100644 index 0000000..a03c510 --- /dev/null +++ b/packages/holzleitner_api/doc/DeliveryCreditResponse.md @@ -0,0 +1,15 @@ +# holzleitner_api.model.DeliveryCreditResponse + +## Load the model package +```dart +import 'package:holzleitner_api/api.dart'; +``` + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**credit** | [**DeliveryCredit**](DeliveryCredit.md) | Aktueller Stand nach dem Ereignis — `None`, wenn (zuletzt) entfernt. | [optional] + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/packages/holzleitner_api/doc/DeliveryItem.md b/packages/holzleitner_api/doc/DeliveryItem.md index 47d138d..b3755c6 100644 --- a/packages/holzleitner_api/doc/DeliveryItem.md +++ b/packages/holzleitner_api/doc/DeliveryItem.md @@ -13,8 +13,10 @@ Name | Type | Description | Notes **deliveryId** | **String** | | **id** | **String** | | **komponentenArtikelNr** | **String** | Bei Items aus einer Stückliste: Artikelnummer der Komponente. Bei regulären Belegzeilen: `None`. | [optional] +**parentArtikelNr** | **String** | Artikelnummer des Oberartikels, zu dem diese Komponente gehört. `None` bei Oberartikeln/regulären Zeilen — die App rückt Komponenten darüber unter ihrem Oberartikel ein. | [optional] **requiredQuantity** | **int** | | **scanState** | [**ScanState**](ScanState.md) | | +**unitPrice** | **double** | Stückpreis (brutto, EUR) aus dem ERP-Sync. Der Warenwert einer Lieferung = Σ `unit_price` × ausgelieferte Menge. | **warehouseId** | **String** | | [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) diff --git a/packages/holzleitner_api/doc/DeliveryNote.md b/packages/holzleitner_api/doc/DeliveryNote.md index 9e09d20..6c64b08 100644 --- a/packages/holzleitner_api/doc/DeliveryNote.md +++ b/packages/holzleitner_api/doc/DeliveryNote.md @@ -11,9 +11,12 @@ Name | Type | Description | Notes **authorCarId** | **String** | Fahrzeug, falls bekannt — nullable bis das Backend Cars verwaltet. | [optional] **authorPersonalnummer** | **int** | Personalnummer des Akteurs (aus dem JWT). Pflicht. | **createdAt** | [**DateTime**](DateTime.md) | | +**creditDeliveryItemId** | **String** | Wenn die Notiz als Gutschrift-Grund zu einer Belegzeile angelegt wurde: deren `DeliveryItem`-Id. Erlaubt dem Client, die Notiz beim Zurücknehmen der Gutschrift (Unremove) gezielt wieder zu löschen. `None` bei normalen Text-/Foto-Notizen. | [optional] **deliveryId** | **String** | | **id** | **String** | | **imageAttachment** | **String** | Referenz auf einen Bild-Anhang (z. B. Object-Storage-Key/URL). | [optional] +**imageAttachmentDeleted** | **bool** | `true`, wenn die lokale Bilddatei nach erfolgreichem Report-Upload gelöscht wurde (das Bild steckt nun im Lieferbericht in DOCUframe). Read-only; die App zeigt dann statt der Vorschau einen Hinweis. Bei Text-Notizen / vorhandenem Bild: `false`. | [optional] +**isAmountCreditNote** | **bool** | `true`, wenn die Notiz den Grund einer **Betrags-Gutschrift** (Geld-Nachlass, Lieferungs-Ebene) dokumentiert. Erlaubt dem Client, sie beim Entfernen der Gutschrift gezielt zu löschen. | **text** | **String** | | [optional] [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) diff --git a/packages/holzleitner_api/doc/DeliveryServiceResponse.md b/packages/holzleitner_api/doc/DeliveryServiceResponse.md new file mode 100644 index 0000000..46cd240 --- /dev/null +++ b/packages/holzleitner_api/doc/DeliveryServiceResponse.md @@ -0,0 +1,15 @@ +# holzleitner_api.model.DeliveryServiceResponse + +## Load the model package +```dart +import 'package:holzleitner_api/api.dart'; +``` + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**value** | [**DeliveryServiceValue**](DeliveryServiceValue.md) | | + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/packages/holzleitner_api/doc/DeliveryServiceValue.md b/packages/holzleitner_api/doc/DeliveryServiceValue.md new file mode 100644 index 0000000..1b7c201 --- /dev/null +++ b/packages/holzleitner_api/doc/DeliveryServiceValue.md @@ -0,0 +1,18 @@ +# holzleitner_api.model.DeliveryServiceValue + +## Load the model package +```dart +import 'package:holzleitner_api/api.dart'; +``` + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**boolValue** | **bool** | | [optional] +**deliveryId** | **String** | | +**numericValue** | **int** | | [optional] +**serviceId** | **String** | | + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/packages/holzleitner_api/doc/DeliveryWithItems.md b/packages/holzleitner_api/doc/DeliveryWithItems.md index 7dd5351..bbdfc2c 100644 --- a/packages/holzleitner_api/doc/DeliveryWithItems.md +++ b/packages/holzleitner_api/doc/DeliveryWithItems.md @@ -16,6 +16,8 @@ Name | Type | Description | Notes **erpBelegartId** | **int** | ERP-Beleg-Bezug: business-stabiles Paar `(Belegart, Belegnummer)`. Überlebt den Belegkopf-Archivübergang. | **erpBelegnummer** | **String** | | **id** | **String** | | +**paymentMethodId** | **String** | Für den Restbetrag gewählte Zahlungsart — FK auf `payment_methods`. Vom Kunden bei Bestellung festgelegt, der Fahrer übernimmt nur die Abwicklung. Aktiv-Flag und Anzeige-Name werden über die Stammdaten-Tabelle aufgelöst, nicht hier embeddet. | +**prepaidAmount** | **double** | Bei Bestellung schon bezahlter Betrag in EUR. `0.0` wenn der Kunde alles bei Lieferung zahlt. Wird vom ERP-Sync gefüllt. | **specialAgreements** | **String** | Sondervereinbarungen (z. B. „Türklingel defekt, hintenrum klopfen\"). | [optional] **state** | [**DeliveryState**](DeliveryState.md) | | **stateReason** | **String** | Begründung bei `state == Held` oder `state == Canceled`. Beim Resume / Complete wieder `None`. | [optional] diff --git a/packages/holzleitner_api/doc/ImportSummary.md b/packages/holzleitner_api/doc/ImportSummary.md new file mode 100644 index 0000000..09cd2b0 --- /dev/null +++ b/packages/holzleitner_api/doc/ImportSummary.md @@ -0,0 +1,21 @@ +# holzleitner_api.model.ImportSummary + +## Load the model package +```dart +import 'package:holzleitner_api/api.dart'; +``` + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**date** | [**Date**](Date.md) | | +**driversProvisioned** | **int** | Anzahl der **neu** im Identity-Provider (Keycloak) angelegten Fahrer-Konten in diesem Lauf (0, wenn Provisionierung deaktiviert ist oder alle Konten bereits existierten). | [optional] +**errors** | **BuiltList<String>** | Fehlertexte je fehlgeschlagener Fahrer-Tour (z. B. unbekannter Fahrer → FK auf `accounts`, oder Validierungsfehler). | +**provisioningErrors** | **BuiltList<String>** | Fehlertexte der Konto-Provisionierung (Keycloak). Best-effort: ein Fehler hier blockiert den Touren-Import **nicht**. | [optional] +**toursFailed** | **int** | | +**toursOk** | **int** | | +**toursTotal** | **int** | | + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/packages/holzleitner_api/doc/MarkMailSentRequest.md b/packages/holzleitner_api/doc/MarkMailSentRequest.md new file mode 100644 index 0000000..3c45a17 --- /dev/null +++ b/packages/holzleitner_api/doc/MarkMailSentRequest.md @@ -0,0 +1,15 @@ +# holzleitner_api.model.MarkMailSentRequest + +## Load the model package +```dart +import 'package:holzleitner_api/api.dart'; +``` + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**belegnummern** | **BuiltList<String>** | Belegnummern, deren Liefermail erfolgreich versendet wurde und die als versendet markiert werden sollen. | + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/packages/holzleitner_api/doc/MarkMailSentResponse.md b/packages/holzleitner_api/doc/MarkMailSentResponse.md new file mode 100644 index 0000000..c8a546f --- /dev/null +++ b/packages/holzleitner_api/doc/MarkMailSentResponse.md @@ -0,0 +1,15 @@ +# holzleitner_api.model.MarkMailSentResponse + +## Load the model package +```dart +import 'package:holzleitner_api/api.dart'; +``` + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**marked** | **int** | Anzahl frisch markierter (vorher offener) Belege. Bereits markierte zählen nicht mit (idempotent). | + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/packages/holzleitner_api/doc/PaymentMethod.md b/packages/holzleitner_api/doc/PaymentMethod.md new file mode 100644 index 0000000..47a3aa0 --- /dev/null +++ b/packages/holzleitner_api/doc/PaymentMethod.md @@ -0,0 +1,19 @@ +# holzleitner_api.model.PaymentMethod + +## Load the model package +```dart +import 'package:holzleitner_api/api.dart'; +``` + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**active** | **bool** | | +**code** | **String** | Stabiler Programm-Identifier — z. B. `\"cash\"`, `\"ec_card\"`. Eindeutig pro Eintrag. Wird vom Aufrufer beim Anlegen gesetzt. | +**createdAt** | [**DateTime**](DateTime.md) | | +**id** | **String** | | +**name** | **String** | Display-Name in der UI — frei via PATCH änderbar. | + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/packages/holzleitner_api/doc/PaymentMethodResponse.md b/packages/holzleitner_api/doc/PaymentMethodResponse.md new file mode 100644 index 0000000..8314fde --- /dev/null +++ b/packages/holzleitner_api/doc/PaymentMethodResponse.md @@ -0,0 +1,15 @@ +# holzleitner_api.model.PaymentMethodResponse + +## Load the model package +```dart +import 'package:holzleitner_api/api.dart'; +``` + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**method** | [**PaymentMethod**](PaymentMethod.md) | | + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/packages/holzleitner_api/doc/PaymentMethodsApi.md b/packages/holzleitner_api/doc/PaymentMethodsApi.md new file mode 100644 index 0000000..bcc82d5 --- /dev/null +++ b/packages/holzleitner_api/doc/PaymentMethodsApi.md @@ -0,0 +1,182 @@ +# holzleitner_api.api.PaymentMethodsApi + +## Load the API package +```dart +import 'package:holzleitner_api/api.dart'; +``` + +All URIs are relative to *http://localhost* + +Method | HTTP request | Description +------------- | ------------- | ------------- +[**createPaymentMethod**](PaymentMethodsApi.md#createpaymentmethod) | **POST** /payment-methods | Legt eine neue Zahlungsmethode an. +[**deletePaymentMethod**](PaymentMethodsApi.md#deletepaymentmethod) | **DELETE** /payment-methods/{id} | Hartes Löschen. `409 Conflict`, wenn die Methode von einer Lieferung referenziert wird — der Admin soll dann den `active = false`-Pfad nutzen. +[**listPaymentMethods**](PaymentMethodsApi.md#listpaymentmethods) | **GET** /payment-methods | Listet die Zahlungsmethoden. +[**updatePaymentMethod**](PaymentMethodsApi.md#updatepaymentmethod) | **PATCH** /payment-methods/{id} | Patcht Anzeige-Name und/oder Aktiv-Flag. + + +# **createPaymentMethod** +> PaymentMethodResponse createPaymentMethod(createPaymentMethodRequest) + +Legt eine neue Zahlungsmethode an. + +### Example +```dart +import 'package:holzleitner_api/api.dart'; + +final api = HolzleitnerApi().getPaymentMethodsApi(); +final CreatePaymentMethodRequest createPaymentMethodRequest = ; // CreatePaymentMethodRequest | + +try { + final response = api.createPaymentMethod(createPaymentMethodRequest); + print(response); +} catch on DioException (e) { + print('Exception when calling PaymentMethodsApi->createPaymentMethod: $e\n'); +} +``` + +### Parameters + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- + **createPaymentMethodRequest** | [**CreatePaymentMethodRequest**](CreatePaymentMethodRequest.md)| | + +### Return type + +[**PaymentMethodResponse**](PaymentMethodResponse.md) + +### Authorization + +[bearer_auth](../README.md#bearer_auth) + +### HTTP request headers + + - **Content-Type**: application/json + - **Accept**: application/json + +[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) + +# **deletePaymentMethod** +> deletePaymentMethod(id) + +Hartes Löschen. `409 Conflict`, wenn die Methode von einer Lieferung referenziert wird — der Admin soll dann den `active = false`-Pfad nutzen. + +### Example +```dart +import 'package:holzleitner_api/api.dart'; + +final api = HolzleitnerApi().getPaymentMethodsApi(); +final String id = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | Zahlungsmethoden-Id + +try { + api.deletePaymentMethod(id); +} catch on DioException (e) { + print('Exception when calling PaymentMethodsApi->deletePaymentMethod: $e\n'); +} +``` + +### Parameters + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- + **id** | **String**| Zahlungsmethoden-Id | + +### Return type + +void (empty response body) + +### Authorization + +[bearer_auth](../README.md#bearer_auth) + +### HTTP request headers + + - **Content-Type**: Not defined + - **Accept**: Not defined + +[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) + +# **listPaymentMethods** +> PaymentMethodsList listPaymentMethods(includeInactive) + +Listet die Zahlungsmethoden. + +### Example +```dart +import 'package:holzleitner_api/api.dart'; + +final api = HolzleitnerApi().getPaymentMethodsApi(); +final bool includeInactive = true; // bool | Wenn true, werden inaktive Methoden mitgeliefert (default: false) + +try { + final response = api.listPaymentMethods(includeInactive); + print(response); +} catch on DioException (e) { + print('Exception when calling PaymentMethodsApi->listPaymentMethods: $e\n'); +} +``` + +### Parameters + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- + **includeInactive** | **bool**| Wenn true, werden inaktive Methoden mitgeliefert (default: false) | [optional] + +### Return type + +[**PaymentMethodsList**](PaymentMethodsList.md) + +### Authorization + +[bearer_auth](../README.md#bearer_auth) + +### HTTP request headers + + - **Content-Type**: Not defined + - **Accept**: application/json + +[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) + +# **updatePaymentMethod** +> PaymentMethodResponse updatePaymentMethod(id, updatePaymentMethodRequest) + +Patcht Anzeige-Name und/oder Aktiv-Flag. + +### Example +```dart +import 'package:holzleitner_api/api.dart'; + +final api = HolzleitnerApi().getPaymentMethodsApi(); +final String id = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | Zahlungsmethoden-Id +final UpdatePaymentMethodRequest updatePaymentMethodRequest = ; // UpdatePaymentMethodRequest | + +try { + final response = api.updatePaymentMethod(id, updatePaymentMethodRequest); + print(response); +} catch on DioException (e) { + print('Exception when calling PaymentMethodsApi->updatePaymentMethod: $e\n'); +} +``` + +### Parameters + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- + **id** | **String**| Zahlungsmethoden-Id | + **updatePaymentMethodRequest** | [**UpdatePaymentMethodRequest**](UpdatePaymentMethodRequest.md)| | + +### Return type + +[**PaymentMethodResponse**](PaymentMethodResponse.md) + +### Authorization + +[bearer_auth](../README.md#bearer_auth) + +### HTTP request headers + + - **Content-Type**: application/json + - **Accept**: application/json + +[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) + diff --git a/packages/holzleitner_api/doc/PaymentMethodsList.md b/packages/holzleitner_api/doc/PaymentMethodsList.md new file mode 100644 index 0000000..a680e6f --- /dev/null +++ b/packages/holzleitner_api/doc/PaymentMethodsList.md @@ -0,0 +1,15 @@ +# holzleitner_api.model.PaymentMethodsList + +## Load the model package +```dart +import 'package:holzleitner_api/api.dart'; +``` + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**methods** | [**BuiltList<PaymentMethod>**](PaymentMethod.md) | | + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/packages/holzleitner_api/doc/ScanEvent.md b/packages/holzleitner_api/doc/ScanEvent.md index 71f05cc..e1fab09 100644 --- a/packages/holzleitner_api/doc/ScanEvent.md +++ b/packages/holzleitner_api/doc/ScanEvent.md @@ -13,6 +13,8 @@ Name | Type | Description | Notes **clientScanId** | **String** | | **clientScannedAt** | [**DateTime**](DateTime.md) | | **deliveryItemId** | **String** | | +**manual** | **bool** | `true`, wenn der Fahrer die Position **manuell** als geladen bestätigt hat (Fallback ohne Barcode-Scan). Wird nur im Audit (`scan_audit.manual`) festgehalten; an der Mengen-/Status-Logik ändert es nichts. Default `false` (regulärer Barcode-Scan). | [optional] +**quantity** | **int** | Menge für `Remove` / `Unremove` (Mengen-Gutschrift): wie viele Stück der Belegzeile gutgeschrieben bzw. wieder hergestellt werden. `None` = ganze Restmenge (abwärtskompatibel zum bisherigen „ganze Zeile entfernen\"). Bei `Scan`/`Unscan`/`Hold`/`Unhold` ignoriert. Muss, wenn gesetzt, `> 0` sein. | [optional] **reason** | **String** | Pflicht bei `Hold` und `Remove`. Sonst ignoriert. | [optional] [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) diff --git a/packages/holzleitner_api/doc/ScanState.md b/packages/holzleitner_api/doc/ScanState.md index 8b8d454..e2c63c4 100644 --- a/packages/holzleitner_api/doc/ScanState.md +++ b/packages/holzleitner_api/doc/ScanState.md @@ -8,6 +8,7 @@ import 'package:holzleitner_api/api.dart'; ## Properties Name | Type | Description | Notes ------------ | ------------- | ------------- | ------------- +**creditedQuantity** | **int** | Als Gutschrift entfernte Menge (0..=required_quantity). Eigene Dimension neben `scanned_quantity`: „wie viele Stück dieser Zeile hat der Kunde nicht angenommen\". `status == Removed` entspricht `credited_quantity == required_quantity` (ganze Zeile gutgeschrieben). | **heldReason** | **String** | Grund bei `status == Held` oder `status == Removed`. | [optional] **lastUpdatedAt** | [**DateTime**](DateTime.md) | | **scannedQuantity** | **int** | | diff --git a/packages/holzleitner_api/doc/Service.md b/packages/holzleitner_api/doc/Service.md new file mode 100644 index 0000000..9d82c1e --- /dev/null +++ b/packages/holzleitner_api/doc/Service.md @@ -0,0 +1,22 @@ +# holzleitner_api.model.Service + +## Load the model package +```dart +import 'package:holzleitner_api/api.dart'; +``` + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**active** | **bool** | | +**id** | **String** | | +**key** | **String** | | +**kind** | [**ServiceKind**](ServiceKind.md) | | +**maxValue** | **int** | | [optional] +**minValue** | **int** | | [optional] +**name** | **String** | | +**sortOrder** | **int** | | + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/packages/holzleitner_api/doc/ServiceKind.md b/packages/holzleitner_api/doc/ServiceKind.md new file mode 100644 index 0000000..1241054 --- /dev/null +++ b/packages/holzleitner_api/doc/ServiceKind.md @@ -0,0 +1,14 @@ +# holzleitner_api.model.ServiceKind + +## Load the model package +```dart +import 'package:holzleitner_api/api.dart'; +``` + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/packages/holzleitner_api/doc/ServiceResponse.md b/packages/holzleitner_api/doc/ServiceResponse.md new file mode 100644 index 0000000..4de458f --- /dev/null +++ b/packages/holzleitner_api/doc/ServiceResponse.md @@ -0,0 +1,15 @@ +# holzleitner_api.model.ServiceResponse + +## Load the model package +```dart +import 'package:holzleitner_api/api.dart'; +``` + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**service** | [**Service**](Service.md) | | + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/packages/holzleitner_api/doc/ServicesApi.md b/packages/holzleitner_api/doc/ServicesApi.md new file mode 100644 index 0000000..e63294b --- /dev/null +++ b/packages/holzleitner_api/doc/ServicesApi.md @@ -0,0 +1,182 @@ +# holzleitner_api.api.ServicesApi + +## Load the API package +```dart +import 'package:holzleitner_api/api.dart'; +``` + +All URIs are relative to *http://localhost* + +Method | HTTP request | Description +------------- | ------------- | ------------- +[**createService**](ServicesApi.md#createservice) | **POST** /services | Legt einen neuen Service an. +[**deleteService**](ServicesApi.md#deleteservice) | **DELETE** /services/{id} | Hartes Löschen. `409 Conflict`, wenn der Service noch von einer Lieferung referenziert wird — dann stattdessen deaktivieren. +[**listServices**](ServicesApi.md#listservices) | **GET** /services | Listet die Services (sortiert nach `sortOrder`). +[**updateService**](ServicesApi.md#updateservice) | **PATCH** /services/{id} | Patcht Name/Grenzen/Aktiv-Flag/Sortierung. `kind` ist nicht änderbar. + + +# **createService** +> ServiceResponse createService(createServiceRequest) + +Legt einen neuen Service an. + +### Example +```dart +import 'package:holzleitner_api/api.dart'; + +final api = HolzleitnerApi().getServicesApi(); +final CreateServiceRequest createServiceRequest = ; // CreateServiceRequest | + +try { + final response = api.createService(createServiceRequest); + print(response); +} catch on DioException (e) { + print('Exception when calling ServicesApi->createService: $e\n'); +} +``` + +### Parameters + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- + **createServiceRequest** | [**CreateServiceRequest**](CreateServiceRequest.md)| | + +### Return type + +[**ServiceResponse**](ServiceResponse.md) + +### Authorization + +[bearer_auth](../README.md#bearer_auth) + +### HTTP request headers + + - **Content-Type**: application/json + - **Accept**: application/json + +[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) + +# **deleteService** +> deleteService(id) + +Hartes Löschen. `409 Conflict`, wenn der Service noch von einer Lieferung referenziert wird — dann stattdessen deaktivieren. + +### Example +```dart +import 'package:holzleitner_api/api.dart'; + +final api = HolzleitnerApi().getServicesApi(); +final String id = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | Service-Id + +try { + api.deleteService(id); +} catch on DioException (e) { + print('Exception when calling ServicesApi->deleteService: $e\n'); +} +``` + +### Parameters + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- + **id** | **String**| Service-Id | + +### Return type + +void (empty response body) + +### Authorization + +[bearer_auth](../README.md#bearer_auth) + +### HTTP request headers + + - **Content-Type**: Not defined + - **Accept**: Not defined + +[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) + +# **listServices** +> ServicesList listServices(includeInactive) + +Listet die Services (sortiert nach `sortOrder`). + +### Example +```dart +import 'package:holzleitner_api/api.dart'; + +final api = HolzleitnerApi().getServicesApi(); +final bool includeInactive = true; // bool | Wenn true, werden inaktive Services mitgeliefert (default: false) + +try { + final response = api.listServices(includeInactive); + print(response); +} catch on DioException (e) { + print('Exception when calling ServicesApi->listServices: $e\n'); +} +``` + +### Parameters + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- + **includeInactive** | **bool**| Wenn true, werden inaktive Services mitgeliefert (default: false) | [optional] + +### Return type + +[**ServicesList**](ServicesList.md) + +### Authorization + +[bearer_auth](../README.md#bearer_auth) + +### HTTP request headers + + - **Content-Type**: Not defined + - **Accept**: application/json + +[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) + +# **updateService** +> ServiceResponse updateService(id, updateServiceRequest) + +Patcht Name/Grenzen/Aktiv-Flag/Sortierung. `kind` ist nicht änderbar. + +### Example +```dart +import 'package:holzleitner_api/api.dart'; + +final api = HolzleitnerApi().getServicesApi(); +final String id = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | Service-Id +final UpdateServiceRequest updateServiceRequest = ; // UpdateServiceRequest | + +try { + final response = api.updateService(id, updateServiceRequest); + print(response); +} catch on DioException (e) { + print('Exception when calling ServicesApi->updateService: $e\n'); +} +``` + +### Parameters + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- + **id** | **String**| Service-Id | + **updateServiceRequest** | [**UpdateServiceRequest**](UpdateServiceRequest.md)| | + +### Return type + +[**ServiceResponse**](ServiceResponse.md) + +### Authorization + +[bearer_auth](../README.md#bearer_auth) + +### HTTP request headers + + - **Content-Type**: application/json + - **Accept**: application/json + +[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) + diff --git a/packages/holzleitner_api/doc/ServicesList.md b/packages/holzleitner_api/doc/ServicesList.md new file mode 100644 index 0000000..72044b2 --- /dev/null +++ b/packages/holzleitner_api/doc/ServicesList.md @@ -0,0 +1,15 @@ +# holzleitner_api.model.ServicesList + +## Load the model package +```dart +import 'package:holzleitner_api/api.dart'; +``` + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**services** | [**BuiltList<Service>**](Service.md) | | + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/packages/holzleitner_api/doc/SetDeliveryServiceRequest.md b/packages/holzleitner_api/doc/SetDeliveryServiceRequest.md new file mode 100644 index 0000000..0ffc8d3 --- /dev/null +++ b/packages/holzleitner_api/doc/SetDeliveryServiceRequest.md @@ -0,0 +1,17 @@ +# holzleitner_api.model.SetDeliveryServiceRequest + +## Load the model package +```dart +import 'package:holzleitner_api/api.dart'; +``` + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**authorCarId** | **String** | | [optional] +**boolValue** | **bool** | | [optional] +**numericValue** | **int** | | [optional] + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/packages/holzleitner_api/doc/SyncContactChannel.md b/packages/holzleitner_api/doc/SyncContactChannel.md new file mode 100644 index 0000000..c70b505 --- /dev/null +++ b/packages/holzleitner_api/doc/SyncContactChannel.md @@ -0,0 +1,17 @@ +# holzleitner_api.model.SyncContactChannel + +## Load the model package +```dart +import 'package:holzleitner_api/api.dart'; +``` + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**kind** | [**ContactKind**](ContactKind.md) | | +**position** | **int** | 1-basiert: spiegelt ERP-Spaltenposition (Telefon → 1, Telefon2 → 2, …). | +**value** | **String** | | + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/packages/holzleitner_api/doc/SyncContactSource.md b/packages/holzleitner_api/doc/SyncContactSource.md new file mode 100644 index 0000000..2f7b6eb --- /dev/null +++ b/packages/holzleitner_api/doc/SyncContactSource.md @@ -0,0 +1,23 @@ +# holzleitner_api.model.SyncContactSource + +## Load the model package +```dart +import 'package:holzleitner_api/api.dart'; +``` + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**abteilung** | **String** | | [optional] +**anrede** | **String** | | [optional] +**channels** | [**BuiltList<SyncContactChannel>**](SyncContactChannel.md) | | [optional] +**funktion** | **String** | | [optional] +**name1** | **String** | | [optional] +**name2** | **String** | | [optional] +**name3** | **String** | | [optional] +**role** | [**ContactRole**](ContactRole.md) | | +**titel** | **String** | | [optional] + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/packages/holzleitner_api/doc/SyncDelivery.md b/packages/holzleitner_api/doc/SyncDelivery.md index 8bb4759..c4375c0 100644 --- a/packages/holzleitner_api/doc/SyncDelivery.md +++ b/packages/holzleitner_api/doc/SyncDelivery.md @@ -8,14 +8,19 @@ import 'package:holzleitner_api/api.dart'; ## Properties Name | Type | Description | Notes ------------ | ------------- | ------------- | ------------- +**belegartCode** | **String** | Belegart-Kurzcode (z. B. „VL5\"), aus `Belegarten.Belegart` (getrimmt). | [optional] **belegartId** | **int** | | +**belegartName** | **String** | Belegart-Klartext (z. B. „Lieferschein EH\"), aus `Belegarten.Bezeichnung`. | [optional] **belegnummer** | **String** | | +**contactSources** | [**BuiltList<SyncContactSource>**](SyncContactSource.md) | Alle vom ERP an diesem Beleg hängenden Kontakt-Adressen (Beleg-/ Liefer-/Rechnungsadresse, Ansprechpartner, Kundenstamm). Leere Quellen (kein einziger ausgefüllter Kanal *und* kein Name) lässt der Sync weg. | [optional] **customerAddress** | [**Address**](Address.md) | | **customerName** | **String** | | **deliveryAddress** | [**Address**](Address.md) | Snapshot der Lieferadresse (kann von der Stammadresse abweichen). | **desiredTime** | **String** | | [optional] **erpCustomerId** | **int** | | **items** | [**BuiltList<SyncDeliveryItem>**](SyncDeliveryItem.md) | | +**paymentMethodCode** | **String** | Für den Restbetrag gewählte Zahlungsart — Referenz per `code` (z. B. `\"cash\"`, `\"invoice\"`). Das ERP kennt seine Standard-Codes, der Sync-Code resolvet sie zur UUID. Wenn `None`, fällt der Backend-Code auf `\"cash\"` zurück. | [optional] +**prepaidAmount** | **double** | Bei Bestellung schon bezahlter Betrag in EUR. Default `0.0`, wenn der Kunde alles bei Lieferung zahlt. Der ERP-Sync liefert den Wert mit. | [optional] **sortOrder** | **int** | 1-basiert, definiert die initiale Reihenfolge in der App. | **specialAgreements** | **String** | | [optional] diff --git a/packages/holzleitner_api/doc/SyncDeliveryItem.md b/packages/holzleitner_api/doc/SyncDeliveryItem.md index b510520..151e1f0 100644 --- a/packages/holzleitner_api/doc/SyncDeliveryItem.md +++ b/packages/holzleitner_api/doc/SyncDeliveryItem.md @@ -13,8 +13,10 @@ Name | Type | Description | Notes **articleNumber** | **String** | | **articleScannable** | **bool** | | **belegzeilenNr** | **int** | | -**komponentenArtikelNr** | **String** | Komponenten-Artikelnummer bei aufgelösten Stücklisten, sonst leer. | [optional] +**komponentenArtikelNr** | **String** | Komponenten-Artikelnummer bei aufgelösten Stücklisten, sonst leer. Trägt die **eigene** Nummer der Komponente (eindeutig je Belegzeile). | [optional] +**parentArtikelNr** | **String** | Artikelnummer des **Oberartikels**, zu dem diese Komponente gehört. `None` bei Oberartikeln/regulären Zeilen. Erlaubt der App, Komponenten unter ihrem Oberartikel einzurücken. | [optional] **requiredQuantity** | **int** | | +**unitPrice** | **double** | Stückpreis (brutto, EUR). Default `0.0`. Liefert der ERP-Sync mit; die App rechnet daraus den Warenwert. | [optional] **warehouseCode** | **String** | | **warehouseName** | **String** | | diff --git a/packages/holzleitner_api/doc/TourDetails.md b/packages/holzleitner_api/doc/TourDetails.md index 9024861..eeac1c6 100644 --- a/packages/holzleitner_api/doc/TourDetails.md +++ b/packages/holzleitner_api/doc/TourDetails.md @@ -9,10 +9,15 @@ import 'package:holzleitner_api/api.dart'; Name | Type | Description | Notes ------------ | ------------- | ------------- | ------------- **articles** | [**BuiltList<Article>**](Article.md) | | +**contactChannels** | [**BuiltList<ContactChannel>**](ContactChannel.md) | Die zu `contact_sources` gehörenden Einzel-Kanäle (Telefon, Mobil, E-Mail, Web). Join per `source_id`. | +**contactSources** | [**BuiltList<ContactSource>**](ContactSource.md) | Alle vom ERP gespiegelten Kontaktquellen aller Lieferungen dieser Tour. Die App joint clientseitig per `delivery_id` und gruppiert nach `role` (Lieferadresse / Rechnungsadresse / Ansprechpartner / Kundenstamm / Belegadresse). | +**credits** | [**BuiltList<DeliveryCredit>**](DeliveryCredit.md) | Aktuelle Betrags-Gutschriften (jüngster Stand pro Lieferung), nur für Lieferungen, deren letztes Ereignis `set` war. Join per `delivery_id`. | **customerContacts** | [**BuiltList<CustomerContact>**](CustomerContact.md) | | **customers** | [**BuiltList<Customer>**](Customer.md) | | **deliveries** | [**BuiltList<DeliveryWithItems>**](DeliveryWithItems.md) | | +**deliveryServices** | [**BuiltList<DeliveryServiceValue>**](DeliveryServiceValue.md) | Pro-Lieferung gesetzte Service-Werte. Join per `delivery_id` + `service_id`. | **notes** | [**BuiltList<DeliveryNote>**](DeliveryNote.md) | Alle Notizen aller Lieferungen dieser Tour, in einer Liste. Die App joint clientseitig per `delivery_id`. Reihenfolge: pro Lieferung aufsteigend nach `created_at`. | +**services** | [**BuiltList<Service>**](Service.md) | Aktive Service-Definitionen (Stammdaten) — die App rendert daraus Phase 4. Bewusst hier mitgeliefert, damit die Detailseite alles aus dem Tour-Aggregat hat. | **tour** | [**Tour**](Tour.md) | | **warehouses** | [**BuiltList<Warehouse>**](Warehouse.md) | | diff --git a/packages/holzleitner_api/doc/UpdateDeliveryNoteRequest.md b/packages/holzleitner_api/doc/UpdateDeliveryNoteRequest.md new file mode 100644 index 0000000..23c71d9 --- /dev/null +++ b/packages/holzleitner_api/doc/UpdateDeliveryNoteRequest.md @@ -0,0 +1,16 @@ +# holzleitner_api.model.UpdateDeliveryNoteRequest + +## Load the model package +```dart +import 'package:holzleitner_api/api.dart'; +``` + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**imageAttachment** | **String** | Object-Storage-Key oder URL eines vorab hochgeladenen Bildes. | [optional] +**text** | **String** | | [optional] + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/packages/holzleitner_api/doc/UpdatePaymentMethodRequest.md b/packages/holzleitner_api/doc/UpdatePaymentMethodRequest.md new file mode 100644 index 0000000..6ae1278 --- /dev/null +++ b/packages/holzleitner_api/doc/UpdatePaymentMethodRequest.md @@ -0,0 +1,16 @@ +# holzleitner_api.model.UpdatePaymentMethodRequest + +## Load the model package +```dart +import 'package:holzleitner_api/api.dart'; +``` + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**active** | **bool** | Wenn gesetzt: aktiv/inaktiv. Inaktive Methoden bleiben für historische Lieferungen referenzierbar, tauchen aber im Default-Listing nicht auf. | [optional] +**name** | **String** | Wenn gesetzt: neuer Anzeige-Name. | [optional] + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/packages/holzleitner_api/doc/UpdateServiceRequest.md b/packages/holzleitner_api/doc/UpdateServiceRequest.md new file mode 100644 index 0000000..e5e49de --- /dev/null +++ b/packages/holzleitner_api/doc/UpdateServiceRequest.md @@ -0,0 +1,19 @@ +# holzleitner_api.model.UpdateServiceRequest + +## Load the model package +```dart +import 'package:holzleitner_api/api.dart'; +``` + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**active** | **bool** | | [optional] +**maxValue** | **int** | | [optional] +**minValue** | **int** | | [optional] +**name** | **String** | | [optional] +**sortOrder** | **int** | | [optional] + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/packages/holzleitner_api/lib/holzleitner_api.dart b/packages/holzleitner_api/lib/holzleitner_api.dart index f040a6d..275b779 100644 --- a/packages/holzleitner_api/lib/holzleitner_api.dart +++ b/packages/holzleitner_api/lib/holzleitner_api.dart @@ -11,10 +11,14 @@ export 'package:holzleitner_api/src/serializers.dart'; export 'package:holzleitner_api/src/model/date.dart'; export 'package:holzleitner_api/src/api/accounts_api.dart'; +export 'package:holzleitner_api/src/api/admin_api.dart'; +export 'package:holzleitner_api/src/api/attachments_api.dart'; export 'package:holzleitner_api/src/api/cars_api.dart'; export 'package:holzleitner_api/src/api/deliveries_api.dart'; export 'package:holzleitner_api/src/api/health_api.dart'; +export 'package:holzleitner_api/src/api/payment_methods_api.dart'; export 'package:holzleitner_api/src/api/scans_api.dart'; +export 'package:holzleitner_api/src/api/services_api.dart'; export 'package:holzleitner_api/src/api/sync_api.dart'; export 'package:holzleitner_api/src/api/tours_api.dart'; @@ -29,26 +33,53 @@ export 'package:holzleitner_api/src/model/cancel_delivery_request.dart'; export 'package:holzleitner_api/src/model/car.dart'; export 'package:holzleitner_api/src/model/car_response.dart'; export 'package:holzleitner_api/src/model/cars_list.dart'; +export 'package:holzleitner_api/src/model/complete_delivery_acknowledgements.dart'; +export 'package:holzleitner_api/src/model/contact_channel.dart'; +export 'package:holzleitner_api/src/model/contact_kind.dart'; +export 'package:holzleitner_api/src/model/contact_role.dart'; +export 'package:holzleitner_api/src/model/contact_source.dart'; export 'package:holzleitner_api/src/model/create_car_request.dart'; export 'package:holzleitner_api/src/model/create_delivery_note_request.dart'; +export 'package:holzleitner_api/src/model/create_payment_method_request.dart'; +export 'package:holzleitner_api/src/model/create_service_request.dart'; +export 'package:holzleitner_api/src/model/credit_action.dart'; export 'package:holzleitner_api/src/model/customer.dart'; export 'package:holzleitner_api/src/model/customer_contact.dart'; +export 'package:holzleitner_api/src/model/delivered_belegnummern_response.dart'; export 'package:holzleitner_api/src/model/delivery.dart'; +export 'package:holzleitner_api/src/model/delivery_credit.dart'; +export 'package:holzleitner_api/src/model/delivery_credit_event_request.dart'; +export 'package:holzleitner_api/src/model/delivery_credit_response.dart'; export 'package:holzleitner_api/src/model/delivery_item.dart'; export 'package:holzleitner_api/src/model/delivery_note.dart'; export 'package:holzleitner_api/src/model/delivery_note_response.dart'; export 'package:holzleitner_api/src/model/delivery_order_entry.dart'; export 'package:holzleitner_api/src/model/delivery_response.dart'; +export 'package:holzleitner_api/src/model/delivery_service_response.dart'; +export 'package:holzleitner_api/src/model/delivery_service_value.dart'; export 'package:holzleitner_api/src/model/delivery_state.dart'; export 'package:holzleitner_api/src/model/delivery_with_items.dart'; export 'package:holzleitner_api/src/model/hold_delivery_request.dart'; +export 'package:holzleitner_api/src/model/import_summary.dart'; +export 'package:holzleitner_api/src/model/mark_mail_sent_request.dart'; +export 'package:holzleitner_api/src/model/mark_mail_sent_response.dart'; +export 'package:holzleitner_api/src/model/payment_method.dart'; +export 'package:holzleitner_api/src/model/payment_method_response.dart'; +export 'package:holzleitner_api/src/model/payment_methods_list.dart'; export 'package:holzleitner_api/src/model/scan_event.dart'; export 'package:holzleitner_api/src/model/scan_result.dart'; export 'package:holzleitner_api/src/model/scan_result_status.dart'; export 'package:holzleitner_api/src/model/scan_state.dart'; export 'package:holzleitner_api/src/model/scan_status.dart'; +export 'package:holzleitner_api/src/model/service.dart'; +export 'package:holzleitner_api/src/model/service_kind.dart'; +export 'package:holzleitner_api/src/model/service_response.dart'; +export 'package:holzleitner_api/src/model/services_list.dart'; export 'package:holzleitner_api/src/model/set_delivery_order_request.dart'; export 'package:holzleitner_api/src/model/set_delivery_order_response.dart'; +export 'package:holzleitner_api/src/model/set_delivery_service_request.dart'; +export 'package:holzleitner_api/src/model/sync_contact_channel.dart'; +export 'package:holzleitner_api/src/model/sync_contact_source.dart'; export 'package:holzleitner_api/src/model/sync_delivery.dart'; export 'package:holzleitner_api/src/model/sync_delivery_item.dart'; export 'package:holzleitner_api/src/model/sync_tour_request.dart'; @@ -58,5 +89,8 @@ export 'package:holzleitner_api/src/model/tour_details.dart'; export 'package:holzleitner_api/src/model/tour_summary.dart'; export 'package:holzleitner_api/src/model/tour_summary_list.dart'; export 'package:holzleitner_api/src/model/update_car_request.dart'; +export 'package:holzleitner_api/src/model/update_delivery_note_request.dart'; +export 'package:holzleitner_api/src/model/update_payment_method_request.dart'; +export 'package:holzleitner_api/src/model/update_service_request.dart'; export 'package:holzleitner_api/src/model/warehouse.dart'; diff --git a/packages/holzleitner_api/lib/src/api.dart b/packages/holzleitner_api/lib/src/api.dart index 312e738..15990a4 100644 --- a/packages/holzleitner_api/lib/src/api.dart +++ b/packages/holzleitner_api/lib/src/api.dart @@ -10,10 +10,14 @@ import 'package:holzleitner_api/src/auth/basic_auth.dart'; import 'package:holzleitner_api/src/auth/bearer_auth.dart'; import 'package:holzleitner_api/src/auth/oauth.dart'; import 'package:holzleitner_api/src/api/accounts_api.dart'; +import 'package:holzleitner_api/src/api/admin_api.dart'; +import 'package:holzleitner_api/src/api/attachments_api.dart'; import 'package:holzleitner_api/src/api/cars_api.dart'; import 'package:holzleitner_api/src/api/deliveries_api.dart'; import 'package:holzleitner_api/src/api/health_api.dart'; +import 'package:holzleitner_api/src/api/payment_methods_api.dart'; import 'package:holzleitner_api/src/api/scans_api.dart'; +import 'package:holzleitner_api/src/api/services_api.dart'; import 'package:holzleitner_api/src/api/sync_api.dart'; import 'package:holzleitner_api/src/api/tours_api.dart'; @@ -77,6 +81,18 @@ class HolzleitnerApi { return AccountsApi(dio, serializers); } + /// Get AdminApi instance, base route and serializer can be overridden by a given but be careful, + /// by doing that all interceptors will not be executed + AdminApi getAdminApi() { + return AdminApi(dio, serializers); + } + + /// Get AttachmentsApi instance, base route and serializer can be overridden by a given but be careful, + /// by doing that all interceptors will not be executed + AttachmentsApi getAttachmentsApi() { + return AttachmentsApi(dio, serializers); + } + /// Get CarsApi instance, base route and serializer can be overridden by a given but be careful, /// by doing that all interceptors will not be executed CarsApi getCarsApi() { @@ -95,12 +111,24 @@ class HolzleitnerApi { return HealthApi(dio, serializers); } + /// Get PaymentMethodsApi instance, base route and serializer can be overridden by a given but be careful, + /// by doing that all interceptors will not be executed + PaymentMethodsApi getPaymentMethodsApi() { + return PaymentMethodsApi(dio, serializers); + } + /// Get ScansApi instance, base route and serializer can be overridden by a given but be careful, /// by doing that all interceptors will not be executed ScansApi getScansApi() { return ScansApi(dio, serializers); } + /// Get ServicesApi instance, base route and serializer can be overridden by a given but be careful, + /// by doing that all interceptors will not be executed + ServicesApi getServicesApi() { + return ServicesApi(dio, serializers); + } + /// Get SyncApi instance, base route and serializer can be overridden by a given but be careful, /// by doing that all interceptors will not be executed SyncApi getSyncApi() { diff --git a/packages/holzleitner_api/lib/src/api/admin_api.dart b/packages/holzleitner_api/lib/src/api/admin_api.dart new file mode 100644 index 0000000..3980f7d --- /dev/null +++ b/packages/holzleitner_api/lib/src/api/admin_api.dart @@ -0,0 +1,360 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// + +import 'dart:async'; + +import 'package:built_value/json_object.dart'; +import 'package:built_value/serializer.dart'; +import 'package:dio/dio.dart'; + +import 'package:holzleitner_api/src/api_util.dart'; +import 'package:holzleitner_api/src/model/delivered_belegnummern_response.dart'; +import 'package:holzleitner_api/src/model/import_summary.dart'; +import 'package:holzleitner_api/src/model/mark_mail_sent_request.dart'; +import 'package:holzleitner_api/src/model/mark_mail_sent_response.dart'; + +class AdminApi { + + final Dio _dio; + + final Serializers _serializers; + + const AdminApi(this._dio, this._serializers); + + /// Liefert die Belegnummern ausgelieferter (abgeschlossener) Lieferungen, **deren Liefermail noch nicht versendet wurde** (`mail_sent_at IS NULL`). „Ausgeliefert\" = es existiert ein Abschluss. Mit `day` (DD-MM-YYYY) nur Abschlüsse dieses Berliner Kalendertages; **ohne `day` alle offenen** (über alle Tage) — so bleiben Belege über Mitternacht nicht hängen. + /// + /// + /// Parameters: + /// * [day] - Tag DD-MM-YYYY; ohne Angabe ALLE offenen Belege + /// * [cancelToken] - A [CancelToken] that can be used to cancel the operation + /// * [headers] - Can be used to add additional headers to the request + /// * [extras] - Can be used to add flags to the request + /// * [validateStatus] - A [ValidateStatus] callback that can be used to determine request success based on the HTTP status of the response + /// * [onSendProgress] - A [ProgressCallback] that can be used to get the send progress + /// * [onReceiveProgress] - A [ProgressCallback] that can be used to get the receive progress + /// + /// Returns a [Future] containing a [Response] with a [DeliveredBelegnummernResponse] as data + /// Throws [DioException] if API call or serialization fails + Future> deliveredBelegnummern({ + String? day, + CancelToken? cancelToken, + Map? headers, + Map? extra, + ValidateStatus? validateStatus, + ProgressCallback? onSendProgress, + ProgressCallback? onReceiveProgress, + }) async { + final _path = r'/admin/delivered-belegnummern'; + final _options = Options( + method: r'GET', + headers: { + ...?headers, + }, + extra: { + 'secure': >[ + { + 'type': 'apiKey', + 'name': 'admin_api_key', + 'keyName': 'X-Admin-Api-Key', + 'where': 'header', + }, + ], + ...?extra, + }, + validateStatus: validateStatus, + ); + + final _queryParameters = { + if (day != null) r'day': encodeQueryParameter(_serializers, day, const FullType(String)), + }; + + final _response = await _dio.request( + _path, + options: _options, + queryParameters: _queryParameters, + cancelToken: cancelToken, + onSendProgress: onSendProgress, + onReceiveProgress: onReceiveProgress, + ); + + DeliveredBelegnummernResponse? _responseData; + + try { + final rawResponse = _response.data; + _responseData = rawResponse == null ? null : _serializers.deserialize( + rawResponse, + specifiedType: const FullType(DeliveredBelegnummernResponse), + ) as DeliveredBelegnummernResponse; + + } catch (error, stackTrace) { + throw DioException( + requestOptions: _response.requestOptions, + response: _response, + type: DioExceptionType.unknown, + error: error, + stackTrace: stackTrace, + ); + } + + return Response( + data: _responseData, + headers: _response.headers, + isRedirect: _response.isRedirect, + requestOptions: _response.requestOptions, + redirects: _response.redirects, + statusCode: _response.statusCode, + statusMessage: _response.statusMessage, + extra: _response.extra, + ); + } + + /// Stößt den ERP-Import für ein Datum an und liefert die Zusammenfassung. + /// + /// + /// Parameters: + /// * [date] - Ziel-Tourdatum YYYY-MM-DD (Default: heute) + /// * [cancelToken] - A [CancelToken] that can be used to cancel the operation + /// * [headers] - Can be used to add additional headers to the request + /// * [extras] - Can be used to add flags to the request + /// * [validateStatus] - A [ValidateStatus] callback that can be used to determine request success based on the HTTP status of the response + /// * [onSendProgress] - A [ProgressCallback] that can be used to get the send progress + /// * [onReceiveProgress] - A [ProgressCallback] that can be used to get the receive progress + /// + /// Returns a [Future] containing a [Response] with a [ImportSummary] as data + /// Throws [DioException] if API call or serialization fails + Future> importErp({ + String? date, + CancelToken? cancelToken, + Map? headers, + Map? extra, + ValidateStatus? validateStatus, + ProgressCallback? onSendProgress, + ProgressCallback? onReceiveProgress, + }) async { + final _path = r'/admin/import-erp'; + final _options = Options( + method: r'POST', + headers: { + ...?headers, + }, + extra: { + 'secure': >[ + { + 'type': 'apiKey', + 'name': 'admin_api_key', + 'keyName': 'X-Admin-Api-Key', + 'where': 'header', + }, + ], + ...?extra, + }, + validateStatus: validateStatus, + ); + + final _queryParameters = { + if (date != null) r'date': encodeQueryParameter(_serializers, date, const FullType(String)), + }; + + final _response = await _dio.request( + _path, + options: _options, + queryParameters: _queryParameters, + cancelToken: cancelToken, + onSendProgress: onSendProgress, + onReceiveProgress: onReceiveProgress, + ); + + ImportSummary? _responseData; + + try { + final rawResponse = _response.data; + _responseData = rawResponse == null ? null : _serializers.deserialize( + rawResponse, + specifiedType: const FullType(ImportSummary), + ) as ImportSummary; + + } catch (error, stackTrace) { + throw DioException( + requestOptions: _response.requestOptions, + response: _response, + type: DioExceptionType.unknown, + error: error, + stackTrace: stackTrace, + ); + } + + return Response( + data: _responseData, + headers: _response.headers, + isRedirect: _response.isRedirect, + requestOptions: _response.requestOptions, + redirects: _response.redirects, + statusCode: _response.statusCode, + statusMessage: _response.statusMessage, + extra: _response.extra, + ); + } + + /// Markiert die Liefermails der angegebenen Belegnummern als **versendet** (`mail_sent_at = now()`, nur wo noch offen). Vom Mailclient aufzurufen, NACHDEM ERPframe die Mails erfolgreich verschickt hat — danach erscheinen die Belege nicht mehr in `GET /admin/delivered-belegnummern`. + /// + /// + /// Parameters: + /// * [markMailSentRequest] + /// * [cancelToken] - A [CancelToken] that can be used to cancel the operation + /// * [headers] - Can be used to add additional headers to the request + /// * [extras] - Can be used to add flags to the request + /// * [validateStatus] - A [ValidateStatus] callback that can be used to determine request success based on the HTTP status of the response + /// * [onSendProgress] - A [ProgressCallback] that can be used to get the send progress + /// * [onReceiveProgress] - A [ProgressCallback] that can be used to get the receive progress + /// + /// Returns a [Future] containing a [Response] with a [MarkMailSentResponse] as data + /// Throws [DioException] if API call or serialization fails + Future> markMailSent({ + required MarkMailSentRequest markMailSentRequest, + CancelToken? cancelToken, + Map? headers, + Map? extra, + ValidateStatus? validateStatus, + ProgressCallback? onSendProgress, + ProgressCallback? onReceiveProgress, + }) async { + final _path = r'/admin/mark-mail-sent'; + final _options = Options( + method: r'POST', + headers: { + ...?headers, + }, + extra: { + 'secure': >[ + { + 'type': 'apiKey', + 'name': 'admin_api_key', + 'keyName': 'X-Admin-Api-Key', + 'where': 'header', + }, + ], + ...?extra, + }, + contentType: 'application/json', + validateStatus: validateStatus, + ); + + dynamic _bodyData; + + try { + const _type = FullType(MarkMailSentRequest); + _bodyData = _serializers.serialize(markMailSentRequest, specifiedType: _type); + + } catch(error, stackTrace) { + throw DioException( + requestOptions: _options.compose( + _dio.options, + _path, + ), + type: DioExceptionType.unknown, + error: error, + stackTrace: stackTrace, + ); + } + + final _response = await _dio.request( + _path, + data: _bodyData, + options: _options, + cancelToken: cancelToken, + onSendProgress: onSendProgress, + onReceiveProgress: onReceiveProgress, + ); + + MarkMailSentResponse? _responseData; + + try { + final rawResponse = _response.data; + _responseData = rawResponse == null ? null : _serializers.deserialize( + rawResponse, + specifiedType: const FullType(MarkMailSentResponse), + ) as MarkMailSentResponse; + + } catch (error, stackTrace) { + throw DioException( + requestOptions: _response.requestOptions, + response: _response, + type: DioExceptionType.unknown, + error: error, + stackTrace: stackTrace, + ); + } + + return Response( + data: _responseData, + headers: _response.headers, + isRedirect: _response.isRedirect, + requestOptions: _response.requestOptions, + redirects: _response.redirects, + statusCode: _response.statusCode, + statusMessage: _response.statusMessage, + extra: _response.extra, + ); + } + + /// Stößt das ERP-Rückschreiben eines bereits lokal abgeschlossenen Lieferabschlusses erneut an (idempotenter Retry, falls der automatische Push beim Abschluss fehlschlug). + /// + /// + /// Parameters: + /// * [deliveryId] - UUID der abgeschlossenen Lieferung + /// * [cancelToken] - A [CancelToken] that can be used to cancel the operation + /// * [headers] - Can be used to add additional headers to the request + /// * [extras] - Can be used to add flags to the request + /// * [validateStatus] - A [ValidateStatus] callback that can be used to determine request success based on the HTTP status of the response + /// * [onSendProgress] - A [ProgressCallback] that can be used to get the send progress + /// * [onReceiveProgress] - A [ProgressCallback] that can be used to get the receive progress + /// + /// Returns a [Future] + /// Throws [DioException] if API call or serialization fails + Future> pushCompletion({ + required String deliveryId, + CancelToken? cancelToken, + Map? headers, + Map? extra, + ValidateStatus? validateStatus, + ProgressCallback? onSendProgress, + ProgressCallback? onReceiveProgress, + }) async { + final _path = r'/admin/push-completion'; + final _options = Options( + method: r'POST', + headers: { + ...?headers, + }, + extra: { + 'secure': >[ + { + 'type': 'apiKey', + 'name': 'admin_api_key', + 'keyName': 'X-Admin-Api-Key', + 'where': 'header', + }, + ], + ...?extra, + }, + validateStatus: validateStatus, + ); + + final _queryParameters = { + r'delivery_id': encodeQueryParameter(_serializers, deliveryId, const FullType(String)), + }; + + final _response = await _dio.request( + _path, + options: _options, + queryParameters: _queryParameters, + cancelToken: cancelToken, + onSendProgress: onSendProgress, + onReceiveProgress: onReceiveProgress, + ); + + return _response; + } + +} diff --git a/packages/holzleitner_api/lib/src/api/attachments_api.dart b/packages/holzleitner_api/lib/src/api/attachments_api.dart new file mode 100644 index 0000000..c7c6181 --- /dev/null +++ b/packages/holzleitner_api/lib/src/api/attachments_api.dart @@ -0,0 +1,93 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// + +import 'dart:async'; + +import 'package:built_value/json_object.dart'; +import 'package:built_value/serializer.dart'; +import 'package:dio/dio.dart'; + +import 'package:holzleitner_api/src/api_util.dart'; + +class AttachmentsApi { + + final Dio _dio; + + final Serializers _serializers; + + const AttachmentsApi(this._dio, this._serializers); + + /// Liefert ein gerendertes Vorschaubild des Attachments (Bytes), geladen aus DOCUframe. Auflösung/Format über Query-Parameter steuerbar (`?w=&h=&q=&ext=&page=`). + /// + /// + /// Parameters: + /// * [id] - Attachment-Id (unsere UUID) + /// * [w] - Breite in Pixeln (Default 1024) + /// * [h] - Höhe in Pixeln (Default 1024) + /// * [q] - Qualität 0–100 (Default 85) + /// * [ext] - png|jpeg|jpg|webp|tiff (Default jpeg) + /// * [page] - Seitennummer (Default 1) + /// * [cancelToken] - A [CancelToken] that can be used to cancel the operation + /// * [headers] - Can be used to add additional headers to the request + /// * [extras] - Can be used to add flags to the request + /// * [validateStatus] - A [ValidateStatus] callback that can be used to determine request success based on the HTTP status of the response + /// * [onSendProgress] - A [ProgressCallback] that can be used to get the send progress + /// * [onReceiveProgress] - A [ProgressCallback] that can be used to get the receive progress + /// + /// Returns a [Future] + /// Throws [DioException] if API call or serialization fails + Future> getAttachment({ + required String id, + int? w, + int? h, + int? q, + String? ext, + String? page, + CancelToken? cancelToken, + Map? headers, + Map? extra, + ValidateStatus? validateStatus, + ProgressCallback? onSendProgress, + ProgressCallback? onReceiveProgress, + }) async { + final _path = r'/attachments/{id}'.replaceAll('{' r'id' '}', encodeQueryParameter(_serializers, id, const FullType(String)).toString()); + final _options = Options( + method: r'GET', + headers: { + ...?headers, + }, + extra: { + 'secure': >[ + { + 'type': 'http', + 'scheme': 'bearer', + 'name': 'bearer_auth', + }, + ], + ...?extra, + }, + validateStatus: validateStatus, + ); + + final _queryParameters = { + if (w != null) r'w': encodeQueryParameter(_serializers, w, const FullType(int)), + if (h != null) r'h': encodeQueryParameter(_serializers, h, const FullType(int)), + if (q != null) r'q': encodeQueryParameter(_serializers, q, const FullType(int)), + if (ext != null) r'ext': encodeQueryParameter(_serializers, ext, const FullType(String)), + if (page != null) r'page': encodeQueryParameter(_serializers, page, const FullType(String)), + }; + + final _response = await _dio.request( + _path, + options: _options, + queryParameters: _queryParameters, + cancelToken: cancelToken, + onSendProgress: onSendProgress, + onReceiveProgress: onReceiveProgress, + ); + + return _response; + } + +} diff --git a/packages/holzleitner_api/lib/src/api/deliveries_api.dart b/packages/holzleitner_api/lib/src/api/deliveries_api.dart index 157dc93..e8ed3d7 100644 --- a/packages/holzleitner_api/lib/src/api/deliveries_api.dart +++ b/packages/holzleitner_api/lib/src/api/deliveries_api.dart @@ -12,9 +12,14 @@ import 'package:holzleitner_api/src/api_util.dart'; import 'package:holzleitner_api/src/model/assign_car_request.dart'; import 'package:holzleitner_api/src/model/cancel_delivery_request.dart'; import 'package:holzleitner_api/src/model/create_delivery_note_request.dart'; +import 'package:holzleitner_api/src/model/delivery_credit_event_request.dart'; +import 'package:holzleitner_api/src/model/delivery_credit_response.dart'; import 'package:holzleitner_api/src/model/delivery_note_response.dart'; import 'package:holzleitner_api/src/model/delivery_response.dart'; +import 'package:holzleitner_api/src/model/delivery_service_response.dart'; import 'package:holzleitner_api/src/model/hold_delivery_request.dart'; +import 'package:holzleitner_api/src/model/set_delivery_service_request.dart'; +import 'package:holzleitner_api/src/model/update_delivery_note_request.dart'; class DeliveriesApi { @@ -24,6 +29,109 @@ class DeliveriesApi { const DeliveriesApi(this._dio, this._serializers); + /// Wendet ein Betrags-Gutschrift-Ereignis an (`set`/`remove`). Append-only, idempotent über `clientEventId`. Nur bei aktiver Lieferung; bei `set` sind Betrag (0 < x ≤ 150 €, 10-€-Schritte) und Grund Pflicht. Antwort: der aktuelle Gutschrift-Stand (`null`, wenn entfernt). + /// + /// + /// Parameters: + /// * [deliveryId] + /// * [deliveryCreditEventRequest] + /// * [cancelToken] - A [CancelToken] that can be used to cancel the operation + /// * [headers] - Can be used to add additional headers to the request + /// * [extras] - Can be used to add flags to the request + /// * [validateStatus] - A [ValidateStatus] callback that can be used to determine request success based on the HTTP status of the response + /// * [onSendProgress] - A [ProgressCallback] that can be used to get the send progress + /// * [onReceiveProgress] - A [ProgressCallback] that can be used to get the receive progress + /// + /// Returns a [Future] containing a [Response] with a [DeliveryCreditResponse] as data + /// Throws [DioException] if API call or serialization fails + Future> applyCredit({ + required String deliveryId, + required DeliveryCreditEventRequest deliveryCreditEventRequest, + CancelToken? cancelToken, + Map? headers, + Map? extra, + ValidateStatus? validateStatus, + ProgressCallback? onSendProgress, + ProgressCallback? onReceiveProgress, + }) async { + final _path = r'/deliveries/{delivery_id}/credit'.replaceAll('{' r'delivery_id' '}', encodeQueryParameter(_serializers, deliveryId, const FullType(String)).toString()); + final _options = Options( + method: r'POST', + headers: { + ...?headers, + }, + extra: { + 'secure': >[ + { + 'type': 'http', + 'scheme': 'bearer', + 'name': 'bearer_auth', + }, + ], + ...?extra, + }, + contentType: 'application/json', + validateStatus: validateStatus, + ); + + dynamic _bodyData; + + try { + const _type = FullType(DeliveryCreditEventRequest); + _bodyData = _serializers.serialize(deliveryCreditEventRequest, specifiedType: _type); + + } catch(error, stackTrace) { + throw DioException( + requestOptions: _options.compose( + _dio.options, + _path, + ), + type: DioExceptionType.unknown, + error: error, + stackTrace: stackTrace, + ); + } + + final _response = await _dio.request( + _path, + data: _bodyData, + options: _options, + cancelToken: cancelToken, + onSendProgress: onSendProgress, + onReceiveProgress: onReceiveProgress, + ); + + DeliveryCreditResponse? _responseData; + + try { + final rawResponse = _response.data; + _responseData = rawResponse == null ? null : _serializers.deserialize( + rawResponse, + specifiedType: const FullType(DeliveryCreditResponse), + ) as DeliveryCreditResponse; + + } catch (error, stackTrace) { + throw DioException( + requestOptions: _response.requestOptions, + response: _response, + type: DioExceptionType.unknown, + error: error, + stackTrace: stackTrace, + ); + } + + return Response( + data: _responseData, + headers: _response.headers, + isRedirect: _response.isRedirect, + requestOptions: _response.requestOptions, + redirects: _response.redirects, + statusCode: _response.statusCode, + statusMessage: _response.statusMessage, + extra: _response.extra, + ); + } + /// Setzt das `assigned_car_id` einer Lieferung. `carId: null` löst die Zuordnung wieder. Der Use Case stellt sicher, dass das Fahrzeug zum angemeldeten Account gehört. /// /// @@ -231,7 +339,7 @@ class DeliveriesApi { } /// Schließt die Lieferung ab — `state = completed`. Nur aus `active`. - /// + /// `multipart/form-data` mit drei Feldern: * `customer_signature` — PNG der Kunden-Unterschrift (Pflicht) * `driver_signature` — PNG der Fahrer-Unterschrift (Pflicht) * `acknowledgements` — JSON (`CompleteDeliveryAcknowledgements`): `receiptConfirmed` (Pflicht true), `notesAcknowledged`, `acknowledgedNoteIds`, `authorCarId`. Atomar: Signaturen werden lokal gespeichert, die Abschluss-Zeile geschrieben und der Status auf `completed` gesetzt — alles oder nichts. Gates: Lieferung aktiv, alle scanbaren Positionen fertig, Notizen bestätigt (falls vorhanden). /// /// Parameters: /// * [deliveryId] @@ -269,6 +377,7 @@ class DeliveriesApi { ], ...?extra, }, + contentType: 'multipart/form-data', validateStatus: validateStatus, ); @@ -414,6 +523,116 @@ class DeliveriesApi { ); } + /// Löscht eine Notiz. Antwortet mit `204 No Content`. + /// + /// + /// Parameters: + /// * [deliveryId] + /// * [noteId] + /// * [cancelToken] - A [CancelToken] that can be used to cancel the operation + /// * [headers] - Can be used to add additional headers to the request + /// * [extras] - Can be used to add flags to the request + /// * [validateStatus] - A [ValidateStatus] callback that can be used to determine request success based on the HTTP status of the response + /// * [onSendProgress] - A [ProgressCallback] that can be used to get the send progress + /// * [onReceiveProgress] - A [ProgressCallback] that can be used to get the receive progress + /// + /// Returns a [Future] + /// Throws [DioException] if API call or serialization fails + Future> deleteNote({ + required String deliveryId, + required String noteId, + CancelToken? cancelToken, + Map? headers, + Map? extra, + ValidateStatus? validateStatus, + ProgressCallback? onSendProgress, + ProgressCallback? onReceiveProgress, + }) async { + final _path = r'/deliveries/{delivery_id}/notes/{note_id}'.replaceAll('{' r'delivery_id' '}', encodeQueryParameter(_serializers, deliveryId, const FullType(String)).toString()).replaceAll('{' r'note_id' '}', encodeQueryParameter(_serializers, noteId, const FullType(String)).toString()); + final _options = Options( + method: r'DELETE', + headers: { + ...?headers, + }, + extra: { + 'secure': >[ + { + 'type': 'http', + 'scheme': 'bearer', + 'name': 'bearer_auth', + }, + ], + ...?extra, + }, + validateStatus: validateStatus, + ); + + final _response = await _dio.request( + _path, + options: _options, + cancelToken: cancelToken, + onSendProgress: onSendProgress, + onReceiveProgress: onReceiveProgress, + ); + + return _response; + } + + /// Entfernt den Service-Wert einer Lieferung (Service „nicht gesetzt\"). Nur bei aktiver Lieferung. Antwort `204`. + /// + /// + /// Parameters: + /// * [deliveryId] + /// * [serviceId] + /// * [cancelToken] - A [CancelToken] that can be used to cancel the operation + /// * [headers] - Can be used to add additional headers to the request + /// * [extras] - Can be used to add flags to the request + /// * [validateStatus] - A [ValidateStatus] callback that can be used to determine request success based on the HTTP status of the response + /// * [onSendProgress] - A [ProgressCallback] that can be used to get the send progress + /// * [onReceiveProgress] - A [ProgressCallback] that can be used to get the receive progress + /// + /// Returns a [Future] + /// Throws [DioException] if API call or serialization fails + Future> deleteServiceValue({ + required String deliveryId, + required String serviceId, + CancelToken? cancelToken, + Map? headers, + Map? extra, + ValidateStatus? validateStatus, + ProgressCallback? onSendProgress, + ProgressCallback? onReceiveProgress, + }) async { + final _path = r'/deliveries/{delivery_id}/services/{service_id}'.replaceAll('{' r'delivery_id' '}', encodeQueryParameter(_serializers, deliveryId, const FullType(String)).toString()).replaceAll('{' r'service_id' '}', encodeQueryParameter(_serializers, serviceId, const FullType(String)).toString()); + final _options = Options( + method: r'DELETE', + headers: { + ...?headers, + }, + extra: { + 'secure': >[ + { + 'type': 'http', + 'scheme': 'bearer', + 'name': 'bearer_auth', + }, + ], + ...?extra, + }, + validateStatus: validateStatus, + ); + + final _response = await _dio.request( + _path, + options: _options, + cancelToken: cancelToken, + onSendProgress: onSendProgress, + onReceiveProgress: onReceiveProgress, + ); + + return _response; + } + /// Setzt die Lieferung auf `held`. Nur aus `active` zulässig. /// /// @@ -598,4 +817,296 @@ class DeliveriesApi { ); } + /// Setzt (Upsert) den Wert eines Service für eine Lieferung. Genau das zum Service-Typ passende Feld (`boolValue`/`numericValue`) muss gesetzt sein; numerische Werte werden gegen min/max geprüft. Nur bei aktiver Lieferung. + /// + /// + /// Parameters: + /// * [deliveryId] + /// * [serviceId] + /// * [setDeliveryServiceRequest] + /// * [cancelToken] - A [CancelToken] that can be used to cancel the operation + /// * [headers] - Can be used to add additional headers to the request + /// * [extras] - Can be used to add flags to the request + /// * [validateStatus] - A [ValidateStatus] callback that can be used to determine request success based on the HTTP status of the response + /// * [onSendProgress] - A [ProgressCallback] that can be used to get the send progress + /// * [onReceiveProgress] - A [ProgressCallback] that can be used to get the receive progress + /// + /// Returns a [Future] containing a [Response] with a [DeliveryServiceResponse] as data + /// Throws [DioException] if API call or serialization fails + Future> setService({ + required String deliveryId, + required String serviceId, + required SetDeliveryServiceRequest setDeliveryServiceRequest, + CancelToken? cancelToken, + Map? headers, + Map? extra, + ValidateStatus? validateStatus, + ProgressCallback? onSendProgress, + ProgressCallback? onReceiveProgress, + }) async { + final _path = r'/deliveries/{delivery_id}/services/{service_id}'.replaceAll('{' r'delivery_id' '}', encodeQueryParameter(_serializers, deliveryId, const FullType(String)).toString()).replaceAll('{' r'service_id' '}', encodeQueryParameter(_serializers, serviceId, const FullType(String)).toString()); + final _options = Options( + method: r'PUT', + headers: { + ...?headers, + }, + extra: { + 'secure': >[ + { + 'type': 'http', + 'scheme': 'bearer', + 'name': 'bearer_auth', + }, + ], + ...?extra, + }, + contentType: 'application/json', + validateStatus: validateStatus, + ); + + dynamic _bodyData; + + try { + const _type = FullType(SetDeliveryServiceRequest); + _bodyData = _serializers.serialize(setDeliveryServiceRequest, specifiedType: _type); + + } catch(error, stackTrace) { + throw DioException( + requestOptions: _options.compose( + _dio.options, + _path, + ), + type: DioExceptionType.unknown, + error: error, + stackTrace: stackTrace, + ); + } + + final _response = await _dio.request( + _path, + data: _bodyData, + options: _options, + cancelToken: cancelToken, + onSendProgress: onSendProgress, + onReceiveProgress: onReceiveProgress, + ); + + DeliveryServiceResponse? _responseData; + + try { + final rawResponse = _response.data; + _responseData = rawResponse == null ? null : _serializers.deserialize( + rawResponse, + specifiedType: const FullType(DeliveryServiceResponse), + ) as DeliveryServiceResponse; + + } catch (error, stackTrace) { + throw DioException( + requestOptions: _response.requestOptions, + response: _response, + type: DioExceptionType.unknown, + error: error, + stackTrace: stackTrace, + ); + } + + return Response( + data: _responseData, + headers: _response.headers, + isRedirect: _response.isRedirect, + requestOptions: _response.requestOptions, + redirects: _response.redirects, + statusCode: _response.statusCode, + statusMessage: _response.statusMessage, + extra: _response.extra, + ); + } + + /// Ändert Text/Bild einer Notiz. Innerhalb des (geteilten) Accounts darf jeder Fahrer Notizen pflegen — kein Autor-Check. `delivery_id` ist Teil des Pfads (REST-Konsistenz), die Notiz wird über `note_id` adressiert. + /// + /// + /// Parameters: + /// * [deliveryId] + /// * [noteId] + /// * [updateDeliveryNoteRequest] + /// * [cancelToken] - A [CancelToken] that can be used to cancel the operation + /// * [headers] - Can be used to add additional headers to the request + /// * [extras] - Can be used to add flags to the request + /// * [validateStatus] - A [ValidateStatus] callback that can be used to determine request success based on the HTTP status of the response + /// * [onSendProgress] - A [ProgressCallback] that can be used to get the send progress + /// * [onReceiveProgress] - A [ProgressCallback] that can be used to get the receive progress + /// + /// Returns a [Future] containing a [Response] with a [DeliveryNoteResponse] as data + /// Throws [DioException] if API call or serialization fails + Future> updateNote({ + required String deliveryId, + required String noteId, + required UpdateDeliveryNoteRequest updateDeliveryNoteRequest, + CancelToken? cancelToken, + Map? headers, + Map? extra, + ValidateStatus? validateStatus, + ProgressCallback? onSendProgress, + ProgressCallback? onReceiveProgress, + }) async { + final _path = r'/deliveries/{delivery_id}/notes/{note_id}'.replaceAll('{' r'delivery_id' '}', encodeQueryParameter(_serializers, deliveryId, const FullType(String)).toString()).replaceAll('{' r'note_id' '}', encodeQueryParameter(_serializers, noteId, const FullType(String)).toString()); + final _options = Options( + method: r'PATCH', + headers: { + ...?headers, + }, + extra: { + 'secure': >[ + { + 'type': 'http', + 'scheme': 'bearer', + 'name': 'bearer_auth', + }, + ], + ...?extra, + }, + contentType: 'application/json', + validateStatus: validateStatus, + ); + + dynamic _bodyData; + + try { + const _type = FullType(UpdateDeliveryNoteRequest); + _bodyData = _serializers.serialize(updateDeliveryNoteRequest, specifiedType: _type); + + } catch(error, stackTrace) { + throw DioException( + requestOptions: _options.compose( + _dio.options, + _path, + ), + type: DioExceptionType.unknown, + error: error, + stackTrace: stackTrace, + ); + } + + final _response = await _dio.request( + _path, + data: _bodyData, + options: _options, + cancelToken: cancelToken, + onSendProgress: onSendProgress, + onReceiveProgress: onReceiveProgress, + ); + + DeliveryNoteResponse? _responseData; + + try { + final rawResponse = _response.data; + _responseData = rawResponse == null ? null : _serializers.deserialize( + rawResponse, + specifiedType: const FullType(DeliveryNoteResponse), + ) as DeliveryNoteResponse; + + } catch (error, stackTrace) { + throw DioException( + requestOptions: _response.requestOptions, + response: _response, + type: DioExceptionType.unknown, + error: error, + stackTrace: stackTrace, + ); + } + + return Response( + data: _responseData, + headers: _response.headers, + isRedirect: _response.isRedirect, + requestOptions: _response.requestOptions, + redirects: _response.redirects, + statusCode: _response.statusCode, + statusMessage: _response.statusMessage, + extra: _response.extra, + ); + } + + /// Lädt ein Bild zu einer Lieferung hoch (multipart/form-data, Feld `file`) und legt dafür eine Bild-Notiz an. Das Bild geht in den DOCUframe-Dokumentenspeicher; gespeichert wird die zurückgelieferte Referenz (`~ObjectID`) als `image_attachment` der Notiz. + /// + /// + /// Parameters: + /// * [deliveryId] + /// * [cancelToken] - A [CancelToken] that can be used to cancel the operation + /// * [headers] - Can be used to add additional headers to the request + /// * [extras] - Can be used to add flags to the request + /// * [validateStatus] - A [ValidateStatus] callback that can be used to determine request success based on the HTTP status of the response + /// * [onSendProgress] - A [ProgressCallback] that can be used to get the send progress + /// * [onReceiveProgress] - A [ProgressCallback] that can be used to get the receive progress + /// + /// Returns a [Future] containing a [Response] with a [DeliveryNoteResponse] as data + /// Throws [DioException] if API call or serialization fails + Future> uploadNoteImage({ + required String deliveryId, + CancelToken? cancelToken, + Map? headers, + Map? extra, + ValidateStatus? validateStatus, + ProgressCallback? onSendProgress, + ProgressCallback? onReceiveProgress, + }) async { + final _path = r'/deliveries/{delivery_id}/notes/image'.replaceAll('{' r'delivery_id' '}', encodeQueryParameter(_serializers, deliveryId, const FullType(String)).toString()); + final _options = Options( + method: r'POST', + headers: { + ...?headers, + }, + extra: { + 'secure': >[ + { + 'type': 'http', + 'scheme': 'bearer', + 'name': 'bearer_auth', + }, + ], + ...?extra, + }, + contentType: 'multipart/form-data', + validateStatus: validateStatus, + ); + + final _response = await _dio.request( + _path, + options: _options, + cancelToken: cancelToken, + onSendProgress: onSendProgress, + onReceiveProgress: onReceiveProgress, + ); + + DeliveryNoteResponse? _responseData; + + try { + final rawResponse = _response.data; + _responseData = rawResponse == null ? null : _serializers.deserialize( + rawResponse, + specifiedType: const FullType(DeliveryNoteResponse), + ) as DeliveryNoteResponse; + + } catch (error, stackTrace) { + throw DioException( + requestOptions: _response.requestOptions, + response: _response, + type: DioExceptionType.unknown, + error: error, + stackTrace: stackTrace, + ); + } + + return Response( + data: _responseData, + headers: _response.headers, + isRedirect: _response.isRedirect, + requestOptions: _response.requestOptions, + redirects: _response.redirects, + statusCode: _response.statusCode, + statusMessage: _response.statusMessage, + extra: _response.extra, + ); + } + } diff --git a/packages/holzleitner_api/lib/src/api/payment_methods_api.dart b/packages/holzleitner_api/lib/src/api/payment_methods_api.dart new file mode 100644 index 0000000..c0f4658 --- /dev/null +++ b/packages/holzleitner_api/lib/src/api/payment_methods_api.dart @@ -0,0 +1,368 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// + +import 'dart:async'; + +import 'package:built_value/json_object.dart'; +import 'package:built_value/serializer.dart'; +import 'package:dio/dio.dart'; + +import 'package:holzleitner_api/src/api_util.dart'; +import 'package:holzleitner_api/src/model/create_payment_method_request.dart'; +import 'package:holzleitner_api/src/model/payment_method_response.dart'; +import 'package:holzleitner_api/src/model/payment_methods_list.dart'; +import 'package:holzleitner_api/src/model/update_payment_method_request.dart'; + +class PaymentMethodsApi { + + final Dio _dio; + + final Serializers _serializers; + + const PaymentMethodsApi(this._dio, this._serializers); + + /// Legt eine neue Zahlungsmethode an. + /// + /// + /// Parameters: + /// * [createPaymentMethodRequest] + /// * [cancelToken] - A [CancelToken] that can be used to cancel the operation + /// * [headers] - Can be used to add additional headers to the request + /// * [extras] - Can be used to add flags to the request + /// * [validateStatus] - A [ValidateStatus] callback that can be used to determine request success based on the HTTP status of the response + /// * [onSendProgress] - A [ProgressCallback] that can be used to get the send progress + /// * [onReceiveProgress] - A [ProgressCallback] that can be used to get the receive progress + /// + /// Returns a [Future] containing a [Response] with a [PaymentMethodResponse] as data + /// Throws [DioException] if API call or serialization fails + Future> createPaymentMethod({ + required CreatePaymentMethodRequest createPaymentMethodRequest, + CancelToken? cancelToken, + Map? headers, + Map? extra, + ValidateStatus? validateStatus, + ProgressCallback? onSendProgress, + ProgressCallback? onReceiveProgress, + }) async { + final _path = r'/payment-methods'; + final _options = Options( + method: r'POST', + headers: { + ...?headers, + }, + extra: { + 'secure': >[ + { + 'type': 'http', + 'scheme': 'bearer', + 'name': 'bearer_auth', + }, + ], + ...?extra, + }, + contentType: 'application/json', + validateStatus: validateStatus, + ); + + dynamic _bodyData; + + try { + const _type = FullType(CreatePaymentMethodRequest); + _bodyData = _serializers.serialize(createPaymentMethodRequest, specifiedType: _type); + + } catch(error, stackTrace) { + throw DioException( + requestOptions: _options.compose( + _dio.options, + _path, + ), + type: DioExceptionType.unknown, + error: error, + stackTrace: stackTrace, + ); + } + + final _response = await _dio.request( + _path, + data: _bodyData, + options: _options, + cancelToken: cancelToken, + onSendProgress: onSendProgress, + onReceiveProgress: onReceiveProgress, + ); + + PaymentMethodResponse? _responseData; + + try { + final rawResponse = _response.data; + _responseData = rawResponse == null ? null : _serializers.deserialize( + rawResponse, + specifiedType: const FullType(PaymentMethodResponse), + ) as PaymentMethodResponse; + + } catch (error, stackTrace) { + throw DioException( + requestOptions: _response.requestOptions, + response: _response, + type: DioExceptionType.unknown, + error: error, + stackTrace: stackTrace, + ); + } + + return Response( + data: _responseData, + headers: _response.headers, + isRedirect: _response.isRedirect, + requestOptions: _response.requestOptions, + redirects: _response.redirects, + statusCode: _response.statusCode, + statusMessage: _response.statusMessage, + extra: _response.extra, + ); + } + + /// Hartes Löschen. `409 Conflict`, wenn die Methode von einer Lieferung referenziert wird — der Admin soll dann den `active = false`-Pfad nutzen. + /// + /// + /// Parameters: + /// * [id] - Zahlungsmethoden-Id + /// * [cancelToken] - A [CancelToken] that can be used to cancel the operation + /// * [headers] - Can be used to add additional headers to the request + /// * [extras] - Can be used to add flags to the request + /// * [validateStatus] - A [ValidateStatus] callback that can be used to determine request success based on the HTTP status of the response + /// * [onSendProgress] - A [ProgressCallback] that can be used to get the send progress + /// * [onReceiveProgress] - A [ProgressCallback] that can be used to get the receive progress + /// + /// Returns a [Future] + /// Throws [DioException] if API call or serialization fails + Future> deletePaymentMethod({ + required String id, + CancelToken? cancelToken, + Map? headers, + Map? extra, + ValidateStatus? validateStatus, + ProgressCallback? onSendProgress, + ProgressCallback? onReceiveProgress, + }) async { + final _path = r'/payment-methods/{id}'.replaceAll('{' r'id' '}', encodeQueryParameter(_serializers, id, const FullType(String)).toString()); + final _options = Options( + method: r'DELETE', + headers: { + ...?headers, + }, + extra: { + 'secure': >[ + { + 'type': 'http', + 'scheme': 'bearer', + 'name': 'bearer_auth', + }, + ], + ...?extra, + }, + validateStatus: validateStatus, + ); + + final _response = await _dio.request( + _path, + options: _options, + cancelToken: cancelToken, + onSendProgress: onSendProgress, + onReceiveProgress: onReceiveProgress, + ); + + return _response; + } + + /// Listet die Zahlungsmethoden. + /// + /// + /// Parameters: + /// * [includeInactive] - Wenn true, werden inaktive Methoden mitgeliefert (default: false) + /// * [cancelToken] - A [CancelToken] that can be used to cancel the operation + /// * [headers] - Can be used to add additional headers to the request + /// * [extras] - Can be used to add flags to the request + /// * [validateStatus] - A [ValidateStatus] callback that can be used to determine request success based on the HTTP status of the response + /// * [onSendProgress] - A [ProgressCallback] that can be used to get the send progress + /// * [onReceiveProgress] - A [ProgressCallback] that can be used to get the receive progress + /// + /// Returns a [Future] containing a [Response] with a [PaymentMethodsList] as data + /// Throws [DioException] if API call or serialization fails + Future> listPaymentMethods({ + bool? includeInactive, + CancelToken? cancelToken, + Map? headers, + Map? extra, + ValidateStatus? validateStatus, + ProgressCallback? onSendProgress, + ProgressCallback? onReceiveProgress, + }) async { + final _path = r'/payment-methods'; + final _options = Options( + method: r'GET', + headers: { + ...?headers, + }, + extra: { + 'secure': >[ + { + 'type': 'http', + 'scheme': 'bearer', + 'name': 'bearer_auth', + }, + ], + ...?extra, + }, + validateStatus: validateStatus, + ); + + final _queryParameters = { + if (includeInactive != null) r'includeInactive': encodeQueryParameter(_serializers, includeInactive, const FullType(bool)), + }; + + final _response = await _dio.request( + _path, + options: _options, + queryParameters: _queryParameters, + cancelToken: cancelToken, + onSendProgress: onSendProgress, + onReceiveProgress: onReceiveProgress, + ); + + PaymentMethodsList? _responseData; + + try { + final rawResponse = _response.data; + _responseData = rawResponse == null ? null : _serializers.deserialize( + rawResponse, + specifiedType: const FullType(PaymentMethodsList), + ) as PaymentMethodsList; + + } catch (error, stackTrace) { + throw DioException( + requestOptions: _response.requestOptions, + response: _response, + type: DioExceptionType.unknown, + error: error, + stackTrace: stackTrace, + ); + } + + return Response( + data: _responseData, + headers: _response.headers, + isRedirect: _response.isRedirect, + requestOptions: _response.requestOptions, + redirects: _response.redirects, + statusCode: _response.statusCode, + statusMessage: _response.statusMessage, + extra: _response.extra, + ); + } + + /// Patcht Anzeige-Name und/oder Aktiv-Flag. + /// + /// + /// Parameters: + /// * [id] - Zahlungsmethoden-Id + /// * [updatePaymentMethodRequest] + /// * [cancelToken] - A [CancelToken] that can be used to cancel the operation + /// * [headers] - Can be used to add additional headers to the request + /// * [extras] - Can be used to add flags to the request + /// * [validateStatus] - A [ValidateStatus] callback that can be used to determine request success based on the HTTP status of the response + /// * [onSendProgress] - A [ProgressCallback] that can be used to get the send progress + /// * [onReceiveProgress] - A [ProgressCallback] that can be used to get the receive progress + /// + /// Returns a [Future] containing a [Response] with a [PaymentMethodResponse] as data + /// Throws [DioException] if API call or serialization fails + Future> updatePaymentMethod({ + required String id, + required UpdatePaymentMethodRequest updatePaymentMethodRequest, + CancelToken? cancelToken, + Map? headers, + Map? extra, + ValidateStatus? validateStatus, + ProgressCallback? onSendProgress, + ProgressCallback? onReceiveProgress, + }) async { + final _path = r'/payment-methods/{id}'.replaceAll('{' r'id' '}', encodeQueryParameter(_serializers, id, const FullType(String)).toString()); + final _options = Options( + method: r'PATCH', + headers: { + ...?headers, + }, + extra: { + 'secure': >[ + { + 'type': 'http', + 'scheme': 'bearer', + 'name': 'bearer_auth', + }, + ], + ...?extra, + }, + contentType: 'application/json', + validateStatus: validateStatus, + ); + + dynamic _bodyData; + + try { + const _type = FullType(UpdatePaymentMethodRequest); + _bodyData = _serializers.serialize(updatePaymentMethodRequest, specifiedType: _type); + + } catch(error, stackTrace) { + throw DioException( + requestOptions: _options.compose( + _dio.options, + _path, + ), + type: DioExceptionType.unknown, + error: error, + stackTrace: stackTrace, + ); + } + + final _response = await _dio.request( + _path, + data: _bodyData, + options: _options, + cancelToken: cancelToken, + onSendProgress: onSendProgress, + onReceiveProgress: onReceiveProgress, + ); + + PaymentMethodResponse? _responseData; + + try { + final rawResponse = _response.data; + _responseData = rawResponse == null ? null : _serializers.deserialize( + rawResponse, + specifiedType: const FullType(PaymentMethodResponse), + ) as PaymentMethodResponse; + + } catch (error, stackTrace) { + throw DioException( + requestOptions: _response.requestOptions, + response: _response, + type: DioExceptionType.unknown, + error: error, + stackTrace: stackTrace, + ); + } + + return Response( + data: _responseData, + headers: _response.headers, + isRedirect: _response.isRedirect, + requestOptions: _response.requestOptions, + redirects: _response.redirects, + statusCode: _response.statusCode, + statusMessage: _response.statusMessage, + extra: _response.extra, + ); + } + +} diff --git a/packages/holzleitner_api/lib/src/api/services_api.dart b/packages/holzleitner_api/lib/src/api/services_api.dart new file mode 100644 index 0000000..9d9d15f --- /dev/null +++ b/packages/holzleitner_api/lib/src/api/services_api.dart @@ -0,0 +1,368 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// + +import 'dart:async'; + +import 'package:built_value/json_object.dart'; +import 'package:built_value/serializer.dart'; +import 'package:dio/dio.dart'; + +import 'package:holzleitner_api/src/api_util.dart'; +import 'package:holzleitner_api/src/model/create_service_request.dart'; +import 'package:holzleitner_api/src/model/service_response.dart'; +import 'package:holzleitner_api/src/model/services_list.dart'; +import 'package:holzleitner_api/src/model/update_service_request.dart'; + +class ServicesApi { + + final Dio _dio; + + final Serializers _serializers; + + const ServicesApi(this._dio, this._serializers); + + /// Legt einen neuen Service an. + /// + /// + /// Parameters: + /// * [createServiceRequest] + /// * [cancelToken] - A [CancelToken] that can be used to cancel the operation + /// * [headers] - Can be used to add additional headers to the request + /// * [extras] - Can be used to add flags to the request + /// * [validateStatus] - A [ValidateStatus] callback that can be used to determine request success based on the HTTP status of the response + /// * [onSendProgress] - A [ProgressCallback] that can be used to get the send progress + /// * [onReceiveProgress] - A [ProgressCallback] that can be used to get the receive progress + /// + /// Returns a [Future] containing a [Response] with a [ServiceResponse] as data + /// Throws [DioException] if API call or serialization fails + Future> createService({ + required CreateServiceRequest createServiceRequest, + CancelToken? cancelToken, + Map? headers, + Map? extra, + ValidateStatus? validateStatus, + ProgressCallback? onSendProgress, + ProgressCallback? onReceiveProgress, + }) async { + final _path = r'/services'; + final _options = Options( + method: r'POST', + headers: { + ...?headers, + }, + extra: { + 'secure': >[ + { + 'type': 'http', + 'scheme': 'bearer', + 'name': 'bearer_auth', + }, + ], + ...?extra, + }, + contentType: 'application/json', + validateStatus: validateStatus, + ); + + dynamic _bodyData; + + try { + const _type = FullType(CreateServiceRequest); + _bodyData = _serializers.serialize(createServiceRequest, specifiedType: _type); + + } catch(error, stackTrace) { + throw DioException( + requestOptions: _options.compose( + _dio.options, + _path, + ), + type: DioExceptionType.unknown, + error: error, + stackTrace: stackTrace, + ); + } + + final _response = await _dio.request( + _path, + data: _bodyData, + options: _options, + cancelToken: cancelToken, + onSendProgress: onSendProgress, + onReceiveProgress: onReceiveProgress, + ); + + ServiceResponse? _responseData; + + try { + final rawResponse = _response.data; + _responseData = rawResponse == null ? null : _serializers.deserialize( + rawResponse, + specifiedType: const FullType(ServiceResponse), + ) as ServiceResponse; + + } catch (error, stackTrace) { + throw DioException( + requestOptions: _response.requestOptions, + response: _response, + type: DioExceptionType.unknown, + error: error, + stackTrace: stackTrace, + ); + } + + return Response( + data: _responseData, + headers: _response.headers, + isRedirect: _response.isRedirect, + requestOptions: _response.requestOptions, + redirects: _response.redirects, + statusCode: _response.statusCode, + statusMessage: _response.statusMessage, + extra: _response.extra, + ); + } + + /// Hartes Löschen. `409 Conflict`, wenn der Service noch von einer Lieferung referenziert wird — dann stattdessen deaktivieren. + /// + /// + /// Parameters: + /// * [id] - Service-Id + /// * [cancelToken] - A [CancelToken] that can be used to cancel the operation + /// * [headers] - Can be used to add additional headers to the request + /// * [extras] - Can be used to add flags to the request + /// * [validateStatus] - A [ValidateStatus] callback that can be used to determine request success based on the HTTP status of the response + /// * [onSendProgress] - A [ProgressCallback] that can be used to get the send progress + /// * [onReceiveProgress] - A [ProgressCallback] that can be used to get the receive progress + /// + /// Returns a [Future] + /// Throws [DioException] if API call or serialization fails + Future> deleteService({ + required String id, + CancelToken? cancelToken, + Map? headers, + Map? extra, + ValidateStatus? validateStatus, + ProgressCallback? onSendProgress, + ProgressCallback? onReceiveProgress, + }) async { + final _path = r'/services/{id}'.replaceAll('{' r'id' '}', encodeQueryParameter(_serializers, id, const FullType(String)).toString()); + final _options = Options( + method: r'DELETE', + headers: { + ...?headers, + }, + extra: { + 'secure': >[ + { + 'type': 'http', + 'scheme': 'bearer', + 'name': 'bearer_auth', + }, + ], + ...?extra, + }, + validateStatus: validateStatus, + ); + + final _response = await _dio.request( + _path, + options: _options, + cancelToken: cancelToken, + onSendProgress: onSendProgress, + onReceiveProgress: onReceiveProgress, + ); + + return _response; + } + + /// Listet die Services (sortiert nach `sortOrder`). + /// + /// + /// Parameters: + /// * [includeInactive] - Wenn true, werden inaktive Services mitgeliefert (default: false) + /// * [cancelToken] - A [CancelToken] that can be used to cancel the operation + /// * [headers] - Can be used to add additional headers to the request + /// * [extras] - Can be used to add flags to the request + /// * [validateStatus] - A [ValidateStatus] callback that can be used to determine request success based on the HTTP status of the response + /// * [onSendProgress] - A [ProgressCallback] that can be used to get the send progress + /// * [onReceiveProgress] - A [ProgressCallback] that can be used to get the receive progress + /// + /// Returns a [Future] containing a [Response] with a [ServicesList] as data + /// Throws [DioException] if API call or serialization fails + Future> listServices({ + bool? includeInactive, + CancelToken? cancelToken, + Map? headers, + Map? extra, + ValidateStatus? validateStatus, + ProgressCallback? onSendProgress, + ProgressCallback? onReceiveProgress, + }) async { + final _path = r'/services'; + final _options = Options( + method: r'GET', + headers: { + ...?headers, + }, + extra: { + 'secure': >[ + { + 'type': 'http', + 'scheme': 'bearer', + 'name': 'bearer_auth', + }, + ], + ...?extra, + }, + validateStatus: validateStatus, + ); + + final _queryParameters = { + if (includeInactive != null) r'includeInactive': encodeQueryParameter(_serializers, includeInactive, const FullType(bool)), + }; + + final _response = await _dio.request( + _path, + options: _options, + queryParameters: _queryParameters, + cancelToken: cancelToken, + onSendProgress: onSendProgress, + onReceiveProgress: onReceiveProgress, + ); + + ServicesList? _responseData; + + try { + final rawResponse = _response.data; + _responseData = rawResponse == null ? null : _serializers.deserialize( + rawResponse, + specifiedType: const FullType(ServicesList), + ) as ServicesList; + + } catch (error, stackTrace) { + throw DioException( + requestOptions: _response.requestOptions, + response: _response, + type: DioExceptionType.unknown, + error: error, + stackTrace: stackTrace, + ); + } + + return Response( + data: _responseData, + headers: _response.headers, + isRedirect: _response.isRedirect, + requestOptions: _response.requestOptions, + redirects: _response.redirects, + statusCode: _response.statusCode, + statusMessage: _response.statusMessage, + extra: _response.extra, + ); + } + + /// Patcht Name/Grenzen/Aktiv-Flag/Sortierung. `kind` ist nicht änderbar. + /// + /// + /// Parameters: + /// * [id] - Service-Id + /// * [updateServiceRequest] + /// * [cancelToken] - A [CancelToken] that can be used to cancel the operation + /// * [headers] - Can be used to add additional headers to the request + /// * [extras] - Can be used to add flags to the request + /// * [validateStatus] - A [ValidateStatus] callback that can be used to determine request success based on the HTTP status of the response + /// * [onSendProgress] - A [ProgressCallback] that can be used to get the send progress + /// * [onReceiveProgress] - A [ProgressCallback] that can be used to get the receive progress + /// + /// Returns a [Future] containing a [Response] with a [ServiceResponse] as data + /// Throws [DioException] if API call or serialization fails + Future> updateService({ + required String id, + required UpdateServiceRequest updateServiceRequest, + CancelToken? cancelToken, + Map? headers, + Map? extra, + ValidateStatus? validateStatus, + ProgressCallback? onSendProgress, + ProgressCallback? onReceiveProgress, + }) async { + final _path = r'/services/{id}'.replaceAll('{' r'id' '}', encodeQueryParameter(_serializers, id, const FullType(String)).toString()); + final _options = Options( + method: r'PATCH', + headers: { + ...?headers, + }, + extra: { + 'secure': >[ + { + 'type': 'http', + 'scheme': 'bearer', + 'name': 'bearer_auth', + }, + ], + ...?extra, + }, + contentType: 'application/json', + validateStatus: validateStatus, + ); + + dynamic _bodyData; + + try { + const _type = FullType(UpdateServiceRequest); + _bodyData = _serializers.serialize(updateServiceRequest, specifiedType: _type); + + } catch(error, stackTrace) { + throw DioException( + requestOptions: _options.compose( + _dio.options, + _path, + ), + type: DioExceptionType.unknown, + error: error, + stackTrace: stackTrace, + ); + } + + final _response = await _dio.request( + _path, + data: _bodyData, + options: _options, + cancelToken: cancelToken, + onSendProgress: onSendProgress, + onReceiveProgress: onReceiveProgress, + ); + + ServiceResponse? _responseData; + + try { + final rawResponse = _response.data; + _responseData = rawResponse == null ? null : _serializers.deserialize( + rawResponse, + specifiedType: const FullType(ServiceResponse), + ) as ServiceResponse; + + } catch (error, stackTrace) { + throw DioException( + requestOptions: _response.requestOptions, + response: _response, + type: DioExceptionType.unknown, + error: error, + stackTrace: stackTrace, + ); + } + + return Response( + data: _responseData, + headers: _response.headers, + isRedirect: _response.isRedirect, + requestOptions: _response.requestOptions, + redirects: _response.redirects, + statusCode: _response.statusCode, + statusMessage: _response.statusMessage, + extra: _response.extra, + ); + } + +} diff --git a/packages/holzleitner_api/lib/src/model/audit_action.dart b/packages/holzleitner_api/lib/src/model/audit_action.dart index 2469092..75597dc 100644 --- a/packages/holzleitner_api/lib/src/model/audit_action.dart +++ b/packages/holzleitner_api/lib/src/model/audit_action.dart @@ -11,21 +11,24 @@ part 'audit_action.g.dart'; class AuditAction extends EnumClass { - /// Aktion-Typen im Scan-Audit-Log. * `Scan` / `Unscan` verändern die `scanned_quantity` (+1 / -1). * `Hold` / `Unhold` ändern nur den Status, keine Menge. * `Remove` markiert die Position als entfernt (Status `Removed`, z. B. weil der Kunde sie nicht annimmt). + /// Aktion-Typen im Scan-Audit-Log. * `Scan` / `Unscan` verändern die `scanned_quantity` (+1 / -1). * `Hold` / `Unhold` ändern nur den Status, keine Menge. * `Remove` markiert die Position als entfernt (Status `Removed`, z. B. weil der Kunde sie nicht annimmt). * `Unremove` hebt ein `Remove` wieder auf — die Position landet zurück in `InProgress` (oder `Done`, falls die `scanned_quantity` schon `required_quantity` erreicht hatte). Der ursprüngliche `Remove`-Audit-Eintrag bleibt unangetastet; das `Unremove` erzeugt einen eigenen Audit-Eintrag — die Historie bleibt vollständig. @BuiltValueEnumConst(wireName: r'scan') static const AuditAction scan = _$scan; - /// Aktion-Typen im Scan-Audit-Log. * `Scan` / `Unscan` verändern die `scanned_quantity` (+1 / -1). * `Hold` / `Unhold` ändern nur den Status, keine Menge. * `Remove` markiert die Position als entfernt (Status `Removed`, z. B. weil der Kunde sie nicht annimmt). + /// Aktion-Typen im Scan-Audit-Log. * `Scan` / `Unscan` verändern die `scanned_quantity` (+1 / -1). * `Hold` / `Unhold` ändern nur den Status, keine Menge. * `Remove` markiert die Position als entfernt (Status `Removed`, z. B. weil der Kunde sie nicht annimmt). * `Unremove` hebt ein `Remove` wieder auf — die Position landet zurück in `InProgress` (oder `Done`, falls die `scanned_quantity` schon `required_quantity` erreicht hatte). Der ursprüngliche `Remove`-Audit-Eintrag bleibt unangetastet; das `Unremove` erzeugt einen eigenen Audit-Eintrag — die Historie bleibt vollständig. @BuiltValueEnumConst(wireName: r'unscan') static const AuditAction unscan = _$unscan; - /// Aktion-Typen im Scan-Audit-Log. * `Scan` / `Unscan` verändern die `scanned_quantity` (+1 / -1). * `Hold` / `Unhold` ändern nur den Status, keine Menge. * `Remove` markiert die Position als entfernt (Status `Removed`, z. B. weil der Kunde sie nicht annimmt). + /// Aktion-Typen im Scan-Audit-Log. * `Scan` / `Unscan` verändern die `scanned_quantity` (+1 / -1). * `Hold` / `Unhold` ändern nur den Status, keine Menge. * `Remove` markiert die Position als entfernt (Status `Removed`, z. B. weil der Kunde sie nicht annimmt). * `Unremove` hebt ein `Remove` wieder auf — die Position landet zurück in `InProgress` (oder `Done`, falls die `scanned_quantity` schon `required_quantity` erreicht hatte). Der ursprüngliche `Remove`-Audit-Eintrag bleibt unangetastet; das `Unremove` erzeugt einen eigenen Audit-Eintrag — die Historie bleibt vollständig. @BuiltValueEnumConst(wireName: r'hold') static const AuditAction hold = _$hold; - /// Aktion-Typen im Scan-Audit-Log. * `Scan` / `Unscan` verändern die `scanned_quantity` (+1 / -1). * `Hold` / `Unhold` ändern nur den Status, keine Menge. * `Remove` markiert die Position als entfernt (Status `Removed`, z. B. weil der Kunde sie nicht annimmt). + /// Aktion-Typen im Scan-Audit-Log. * `Scan` / `Unscan` verändern die `scanned_quantity` (+1 / -1). * `Hold` / `Unhold` ändern nur den Status, keine Menge. * `Remove` markiert die Position als entfernt (Status `Removed`, z. B. weil der Kunde sie nicht annimmt). * `Unremove` hebt ein `Remove` wieder auf — die Position landet zurück in `InProgress` (oder `Done`, falls die `scanned_quantity` schon `required_quantity` erreicht hatte). Der ursprüngliche `Remove`-Audit-Eintrag bleibt unangetastet; das `Unremove` erzeugt einen eigenen Audit-Eintrag — die Historie bleibt vollständig. @BuiltValueEnumConst(wireName: r'unhold') static const AuditAction unhold = _$unhold; - /// Aktion-Typen im Scan-Audit-Log. * `Scan` / `Unscan` verändern die `scanned_quantity` (+1 / -1). * `Hold` / `Unhold` ändern nur den Status, keine Menge. * `Remove` markiert die Position als entfernt (Status `Removed`, z. B. weil der Kunde sie nicht annimmt). + /// Aktion-Typen im Scan-Audit-Log. * `Scan` / `Unscan` verändern die `scanned_quantity` (+1 / -1). * `Hold` / `Unhold` ändern nur den Status, keine Menge. * `Remove` markiert die Position als entfernt (Status `Removed`, z. B. weil der Kunde sie nicht annimmt). * `Unremove` hebt ein `Remove` wieder auf — die Position landet zurück in `InProgress` (oder `Done`, falls die `scanned_quantity` schon `required_quantity` erreicht hatte). Der ursprüngliche `Remove`-Audit-Eintrag bleibt unangetastet; das `Unremove` erzeugt einen eigenen Audit-Eintrag — die Historie bleibt vollständig. @BuiltValueEnumConst(wireName: r'remove') static const AuditAction remove = _$remove; + /// Aktion-Typen im Scan-Audit-Log. * `Scan` / `Unscan` verändern die `scanned_quantity` (+1 / -1). * `Hold` / `Unhold` ändern nur den Status, keine Menge. * `Remove` markiert die Position als entfernt (Status `Removed`, z. B. weil der Kunde sie nicht annimmt). * `Unremove` hebt ein `Remove` wieder auf — die Position landet zurück in `InProgress` (oder `Done`, falls die `scanned_quantity` schon `required_quantity` erreicht hatte). Der ursprüngliche `Remove`-Audit-Eintrag bleibt unangetastet; das `Unremove` erzeugt einen eigenen Audit-Eintrag — die Historie bleibt vollständig. + @BuiltValueEnumConst(wireName: r'unremove') + static const AuditAction unremove = _$unremove; static Serializer get serializer => _$auditActionSerializer; diff --git a/packages/holzleitner_api/lib/src/model/audit_action.g.dart b/packages/holzleitner_api/lib/src/model/audit_action.g.dart index 5c3075c..47f2337 100644 --- a/packages/holzleitner_api/lib/src/model/audit_action.g.dart +++ b/packages/holzleitner_api/lib/src/model/audit_action.g.dart @@ -11,6 +11,7 @@ const AuditAction _$unscan = const AuditAction._('unscan'); const AuditAction _$hold = const AuditAction._('hold'); const AuditAction _$unhold = const AuditAction._('unhold'); const AuditAction _$remove = const AuditAction._('remove'); +const AuditAction _$unremove = const AuditAction._('unremove'); AuditAction _$valueOf(String name) { switch (name) { @@ -24,6 +25,8 @@ AuditAction _$valueOf(String name) { return _$unhold; case 'remove': return _$remove; + case 'unremove': + return _$unremove; default: throw ArgumentError(name); } @@ -36,6 +39,7 @@ final BuiltSet _$values = _$hold, _$unhold, _$remove, + _$unremove, ]); class _$AuditActionMeta { @@ -45,6 +49,7 @@ class _$AuditActionMeta { AuditAction get hold => _$hold; AuditAction get unhold => _$unhold; AuditAction get remove => _$remove; + AuditAction get unremove => _$unremove; AuditAction valueOf(String name) => _$valueOf(name); BuiltSet get values => _$values; } @@ -63,6 +68,7 @@ class _$AuditActionSerializer implements PrimitiveSerializer { 'hold': 'hold', 'unhold': 'unhold', 'remove': 'remove', + 'unremove': 'unremove', }; static const Map _fromWire = const { 'scan': 'scan', @@ -70,6 +76,7 @@ class _$AuditActionSerializer implements PrimitiveSerializer { 'hold': 'hold', 'unhold': 'unhold', 'remove': 'remove', + 'unremove': 'unremove', }; @override diff --git a/packages/holzleitner_api/lib/src/model/complete_delivery_acknowledgements.dart b/packages/holzleitner_api/lib/src/model/complete_delivery_acknowledgements.dart new file mode 100644 index 0000000..769ac56 --- /dev/null +++ b/packages/holzleitner_api/lib/src/model/complete_delivery_acknowledgements.dart @@ -0,0 +1,205 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// + +// ignore_for_file: unused_element +import 'package:built_collection/built_collection.dart'; +import 'package:built_value/built_value.dart'; +import 'package:built_value/serializer.dart'; + +part 'complete_delivery_acknowledgements.g.dart'; + +/// Dokumentierte Bestätigungen des Kunden zum Abschlusszeitpunkt. +/// +/// Properties: +/// * [acknowledgedNoteIds] - Notiz-IDs, die zum Abschlusszeitpunkt sichtbar waren und mit-bestätigt wurden (Audit-Robustheit). +/// * [authorCarId] - Fahrzeug des Akteurs (Audit-Spur). Muss zum Account gehören. +/// * [notesAcknowledged] - „Anmerkungen zur Lieferung zur Kenntnis genommen.\" — Pflicht nur, wenn Notizen existieren (das prüft der Server). +/// * [paymentCollected] - Inkasso-Bestätigung des Fahrers: „der offene Betrag wurde erhalten (bar) bzw. über das EC-Gerät abgerechnet.\" Pflicht nur, wenn beim Abschluss ein offener Betrag > 0 besteht UND die Methode ein Vor-Ort- Inkasso ist (Bar/EC) — das prüft der Server. Der kassierte Betrag wird server-seitig autoritativ berechnet (nicht vom Client übernommen). +/// * [paymentMethodId] - Optionale Zahlungsmethode, die der Fahrer beim Abschluss gewählt hat. `None` = die am Beleg hinterlegte Methode bleibt. Falls gesetzt, muss sie existieren **und** aktiv sein (vom Server geprüft). +/// * [receiptConfirmed] - „Ware im ordnungsgemäßen Zustand erhalten / Aufbau korrekt.\" — Pflicht. +@BuiltValue() +abstract class CompleteDeliveryAcknowledgements implements Built { + /// Notiz-IDs, die zum Abschlusszeitpunkt sichtbar waren und mit-bestätigt wurden (Audit-Robustheit). + @BuiltValueField(wireName: r'acknowledgedNoteIds') + BuiltList? get acknowledgedNoteIds; + + /// Fahrzeug des Akteurs (Audit-Spur). Muss zum Account gehören. + @BuiltValueField(wireName: r'authorCarId') + String? get authorCarId; + + /// „Anmerkungen zur Lieferung zur Kenntnis genommen.\" — Pflicht nur, wenn Notizen existieren (das prüft der Server). + @BuiltValueField(wireName: r'notesAcknowledged') + bool? get notesAcknowledged; + + /// Inkasso-Bestätigung des Fahrers: „der offene Betrag wurde erhalten (bar) bzw. über das EC-Gerät abgerechnet.\" Pflicht nur, wenn beim Abschluss ein offener Betrag > 0 besteht UND die Methode ein Vor-Ort- Inkasso ist (Bar/EC) — das prüft der Server. Der kassierte Betrag wird server-seitig autoritativ berechnet (nicht vom Client übernommen). + @BuiltValueField(wireName: r'paymentCollected') + bool? get paymentCollected; + + /// Optionale Zahlungsmethode, die der Fahrer beim Abschluss gewählt hat. `None` = die am Beleg hinterlegte Methode bleibt. Falls gesetzt, muss sie existieren **und** aktiv sein (vom Server geprüft). + @BuiltValueField(wireName: r'paymentMethodId') + String? get paymentMethodId; + + /// „Ware im ordnungsgemäßen Zustand erhalten / Aufbau korrekt.\" — Pflicht. + @BuiltValueField(wireName: r'receiptConfirmed') + bool get receiptConfirmed; + + CompleteDeliveryAcknowledgements._(); + + factory CompleteDeliveryAcknowledgements([void updates(CompleteDeliveryAcknowledgementsBuilder b)]) = _$CompleteDeliveryAcknowledgements; + + @BuiltValueHook(initializeBuilder: true) + static void _defaults(CompleteDeliveryAcknowledgementsBuilder b) => b; + + @BuiltValueSerializer(custom: true) + static Serializer get serializer => _$CompleteDeliveryAcknowledgementsSerializer(); +} + +class _$CompleteDeliveryAcknowledgementsSerializer implements PrimitiveSerializer { + @override + final Iterable types = const [CompleteDeliveryAcknowledgements, _$CompleteDeliveryAcknowledgements]; + + @override + final String wireName = r'CompleteDeliveryAcknowledgements'; + + Iterable _serializeProperties( + Serializers serializers, + CompleteDeliveryAcknowledgements object, { + FullType specifiedType = FullType.unspecified, + }) sync* { + if (object.acknowledgedNoteIds != null) { + yield r'acknowledgedNoteIds'; + yield serializers.serialize( + object.acknowledgedNoteIds, + specifiedType: const FullType(BuiltList, [FullType(String)]), + ); + } + if (object.authorCarId != null) { + yield r'authorCarId'; + yield serializers.serialize( + object.authorCarId, + specifiedType: const FullType.nullable(String), + ); + } + if (object.notesAcknowledged != null) { + yield r'notesAcknowledged'; + yield serializers.serialize( + object.notesAcknowledged, + specifiedType: const FullType(bool), + ); + } + if (object.paymentCollected != null) { + yield r'paymentCollected'; + yield serializers.serialize( + object.paymentCollected, + specifiedType: const FullType(bool), + ); + } + if (object.paymentMethodId != null) { + yield r'paymentMethodId'; + yield serializers.serialize( + object.paymentMethodId, + specifiedType: const FullType.nullable(String), + ); + } + yield r'receiptConfirmed'; + yield serializers.serialize( + object.receiptConfirmed, + specifiedType: const FullType(bool), + ); + } + + @override + Object serialize( + Serializers serializers, + CompleteDeliveryAcknowledgements object, { + FullType specifiedType = FullType.unspecified, + }) { + return _serializeProperties(serializers, object, specifiedType: specifiedType).toList(); + } + + void _deserializeProperties( + Serializers serializers, + Object serialized, { + FullType specifiedType = FullType.unspecified, + required List serializedList, + required CompleteDeliveryAcknowledgementsBuilder result, + required List unhandled, + }) { + for (var i = 0; i < serializedList.length; i += 2) { + final key = serializedList[i] as String; + final value = serializedList[i + 1]; + switch (key) { + case r'acknowledgedNoteIds': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType(BuiltList, [FullType(String)]), + ) as BuiltList; + result.acknowledgedNoteIds.replace(valueDes); + break; + case r'authorCarId': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType.nullable(String), + ) as String?; + if (valueDes == null) continue; + result.authorCarId = valueDes; + break; + case r'notesAcknowledged': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType(bool), + ) as bool; + result.notesAcknowledged = valueDes; + break; + case r'paymentCollected': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType(bool), + ) as bool; + result.paymentCollected = valueDes; + break; + case r'paymentMethodId': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType.nullable(String), + ) as String?; + if (valueDes == null) continue; + result.paymentMethodId = valueDes; + break; + case r'receiptConfirmed': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType(bool), + ) as bool; + result.receiptConfirmed = valueDes; + break; + default: + unhandled.add(key); + unhandled.add(value); + break; + } + } + } + + @override + CompleteDeliveryAcknowledgements deserialize( + Serializers serializers, + Object serialized, { + FullType specifiedType = FullType.unspecified, + }) { + final result = CompleteDeliveryAcknowledgementsBuilder(); + final serializedList = (serialized as Iterable).toList(); + final unhandled = []; + _deserializeProperties( + serializers, + serialized, + specifiedType: specifiedType, + serializedList: serializedList, + unhandled: unhandled, + result: result, + ); + return result.build(); + } +} + diff --git a/packages/holzleitner_api/lib/src/model/complete_delivery_acknowledgements.g.dart b/packages/holzleitner_api/lib/src/model/complete_delivery_acknowledgements.g.dart new file mode 100644 index 0000000..5a1fa51 --- /dev/null +++ b/packages/holzleitner_api/lib/src/model/complete_delivery_acknowledgements.g.dart @@ -0,0 +1,181 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'complete_delivery_acknowledgements.dart'; + +// ************************************************************************** +// BuiltValueGenerator +// ************************************************************************** + +class _$CompleteDeliveryAcknowledgements + extends CompleteDeliveryAcknowledgements { + @override + final BuiltList? acknowledgedNoteIds; + @override + final String? authorCarId; + @override + final bool? notesAcknowledged; + @override + final bool? paymentCollected; + @override + final String? paymentMethodId; + @override + final bool receiptConfirmed; + + factory _$CompleteDeliveryAcknowledgements( + [void Function(CompleteDeliveryAcknowledgementsBuilder)? updates]) => + (CompleteDeliveryAcknowledgementsBuilder()..update(updates))._build(); + + _$CompleteDeliveryAcknowledgements._( + {this.acknowledgedNoteIds, + this.authorCarId, + this.notesAcknowledged, + this.paymentCollected, + this.paymentMethodId, + required this.receiptConfirmed}) + : super._(); + @override + CompleteDeliveryAcknowledgements rebuild( + void Function(CompleteDeliveryAcknowledgementsBuilder) updates) => + (toBuilder()..update(updates)).build(); + + @override + CompleteDeliveryAcknowledgementsBuilder toBuilder() => + CompleteDeliveryAcknowledgementsBuilder()..replace(this); + + @override + bool operator ==(Object other) { + if (identical(other, this)) return true; + return other is CompleteDeliveryAcknowledgements && + acknowledgedNoteIds == other.acknowledgedNoteIds && + authorCarId == other.authorCarId && + notesAcknowledged == other.notesAcknowledged && + paymentCollected == other.paymentCollected && + paymentMethodId == other.paymentMethodId && + receiptConfirmed == other.receiptConfirmed; + } + + @override + int get hashCode { + var _$hash = 0; + _$hash = $jc(_$hash, acknowledgedNoteIds.hashCode); + _$hash = $jc(_$hash, authorCarId.hashCode); + _$hash = $jc(_$hash, notesAcknowledged.hashCode); + _$hash = $jc(_$hash, paymentCollected.hashCode); + _$hash = $jc(_$hash, paymentMethodId.hashCode); + _$hash = $jc(_$hash, receiptConfirmed.hashCode); + _$hash = $jf(_$hash); + return _$hash; + } + + @override + String toString() { + return (newBuiltValueToStringHelper(r'CompleteDeliveryAcknowledgements') + ..add('acknowledgedNoteIds', acknowledgedNoteIds) + ..add('authorCarId', authorCarId) + ..add('notesAcknowledged', notesAcknowledged) + ..add('paymentCollected', paymentCollected) + ..add('paymentMethodId', paymentMethodId) + ..add('receiptConfirmed', receiptConfirmed)) + .toString(); + } +} + +class CompleteDeliveryAcknowledgementsBuilder + implements + Builder { + _$CompleteDeliveryAcknowledgements? _$v; + + ListBuilder? _acknowledgedNoteIds; + ListBuilder get acknowledgedNoteIds => + _$this._acknowledgedNoteIds ??= ListBuilder(); + set acknowledgedNoteIds(ListBuilder? acknowledgedNoteIds) => + _$this._acknowledgedNoteIds = acknowledgedNoteIds; + + String? _authorCarId; + String? get authorCarId => _$this._authorCarId; + set authorCarId(String? authorCarId) => _$this._authorCarId = authorCarId; + + bool? _notesAcknowledged; + bool? get notesAcknowledged => _$this._notesAcknowledged; + set notesAcknowledged(bool? notesAcknowledged) => + _$this._notesAcknowledged = notesAcknowledged; + + bool? _paymentCollected; + bool? get paymentCollected => _$this._paymentCollected; + set paymentCollected(bool? paymentCollected) => + _$this._paymentCollected = paymentCollected; + + String? _paymentMethodId; + String? get paymentMethodId => _$this._paymentMethodId; + set paymentMethodId(String? paymentMethodId) => + _$this._paymentMethodId = paymentMethodId; + + bool? _receiptConfirmed; + bool? get receiptConfirmed => _$this._receiptConfirmed; + set receiptConfirmed(bool? receiptConfirmed) => + _$this._receiptConfirmed = receiptConfirmed; + + CompleteDeliveryAcknowledgementsBuilder() { + CompleteDeliveryAcknowledgements._defaults(this); + } + + CompleteDeliveryAcknowledgementsBuilder get _$this { + final $v = _$v; + if ($v != null) { + _acknowledgedNoteIds = $v.acknowledgedNoteIds?.toBuilder(); + _authorCarId = $v.authorCarId; + _notesAcknowledged = $v.notesAcknowledged; + _paymentCollected = $v.paymentCollected; + _paymentMethodId = $v.paymentMethodId; + _receiptConfirmed = $v.receiptConfirmed; + _$v = null; + } + return this; + } + + @override + void replace(CompleteDeliveryAcknowledgements other) { + _$v = other as _$CompleteDeliveryAcknowledgements; + } + + @override + void update(void Function(CompleteDeliveryAcknowledgementsBuilder)? updates) { + if (updates != null) updates(this); + } + + @override + CompleteDeliveryAcknowledgements build() => _build(); + + _$CompleteDeliveryAcknowledgements _build() { + _$CompleteDeliveryAcknowledgements _$result; + try { + _$result = _$v ?? + _$CompleteDeliveryAcknowledgements._( + acknowledgedNoteIds: _acknowledgedNoteIds?.build(), + authorCarId: authorCarId, + notesAcknowledged: notesAcknowledged, + paymentCollected: paymentCollected, + paymentMethodId: paymentMethodId, + receiptConfirmed: BuiltValueNullFieldError.checkNotNull( + receiptConfirmed, + r'CompleteDeliveryAcknowledgements', + 'receiptConfirmed'), + ); + } catch (_) { + late String _$failedField; + try { + _$failedField = 'acknowledgedNoteIds'; + _acknowledgedNoteIds?.build(); + } catch (e) { + throw BuiltValueNestedFieldError( + r'CompleteDeliveryAcknowledgements', _$failedField, e.toString()); + } + rethrow; + } + replace(_$result); + return _$result; + } +} + +// ignore_for_file: deprecated_member_use_from_same_package,type=lint diff --git a/packages/holzleitner_api/lib/src/model/contact_channel.dart b/packages/holzleitner_api/lib/src/model/contact_channel.dart new file mode 100644 index 0000000..d90b421 --- /dev/null +++ b/packages/holzleitner_api/lib/src/model/contact_channel.dart @@ -0,0 +1,172 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// + +// ignore_for_file: unused_element +import 'package:holzleitner_api/src/model/contact_kind.dart'; +import 'package:built_value/built_value.dart'; +import 'package:built_value/serializer.dart'; + +part 'contact_channel.g.dart'; + +/// Ein einzelner Kontaktkanal (Telefonnummer / Mobil / E-Mail / Web). Mehrere pro [`ContactSource`] möglich, die `position` hält die 1-basierte ERP-Reihenfolge (`Telefon` → 1, `Telefon2` → 2 usw.) fest, damit der „primäre\" Kanal je Art stabil identifizierbar bleibt. +/// +/// Properties: +/// * [id] +/// * [kind] +/// * [position] +/// * [sourceId] +/// * [value] +@BuiltValue() +abstract class ContactChannel implements Built { + @BuiltValueField(wireName: r'id') + String get id; + + @BuiltValueField(wireName: r'kind') + ContactKind get kind; + // enum kindEnum { phone, mobile, email, web, }; + + @BuiltValueField(wireName: r'position') + int get position; + + @BuiltValueField(wireName: r'sourceId') + String get sourceId; + + @BuiltValueField(wireName: r'value') + String get value; + + ContactChannel._(); + + factory ContactChannel([void updates(ContactChannelBuilder b)]) = _$ContactChannel; + + @BuiltValueHook(initializeBuilder: true) + static void _defaults(ContactChannelBuilder b) => b; + + @BuiltValueSerializer(custom: true) + static Serializer get serializer => _$ContactChannelSerializer(); +} + +class _$ContactChannelSerializer implements PrimitiveSerializer { + @override + final Iterable types = const [ContactChannel, _$ContactChannel]; + + @override + final String wireName = r'ContactChannel'; + + Iterable _serializeProperties( + Serializers serializers, + ContactChannel object, { + FullType specifiedType = FullType.unspecified, + }) sync* { + yield r'id'; + yield serializers.serialize( + object.id, + specifiedType: const FullType(String), + ); + yield r'kind'; + yield serializers.serialize( + object.kind, + specifiedType: const FullType(ContactKind), + ); + yield r'position'; + yield serializers.serialize( + object.position, + specifiedType: const FullType(int), + ); + yield r'sourceId'; + yield serializers.serialize( + object.sourceId, + specifiedType: const FullType(String), + ); + yield r'value'; + yield serializers.serialize( + object.value, + specifiedType: const FullType(String), + ); + } + + @override + Object serialize( + Serializers serializers, + ContactChannel object, { + FullType specifiedType = FullType.unspecified, + }) { + return _serializeProperties(serializers, object, specifiedType: specifiedType).toList(); + } + + void _deserializeProperties( + Serializers serializers, + Object serialized, { + FullType specifiedType = FullType.unspecified, + required List serializedList, + required ContactChannelBuilder result, + required List unhandled, + }) { + for (var i = 0; i < serializedList.length; i += 2) { + final key = serializedList[i] as String; + final value = serializedList[i + 1]; + switch (key) { + case r'id': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType(String), + ) as String; + result.id = valueDes; + break; + case r'kind': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType(ContactKind), + ) as ContactKind; + result.kind = valueDes; + break; + case r'position': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType(int), + ) as int; + result.position = valueDes; + break; + case r'sourceId': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType(String), + ) as String; + result.sourceId = valueDes; + break; + case r'value': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType(String), + ) as String; + result.value = valueDes; + break; + default: + unhandled.add(key); + unhandled.add(value); + break; + } + } + } + + @override + ContactChannel deserialize( + Serializers serializers, + Object serialized, { + FullType specifiedType = FullType.unspecified, + }) { + final result = ContactChannelBuilder(); + final serializedList = (serialized as Iterable).toList(); + final unhandled = []; + _deserializeProperties( + serializers, + serialized, + specifiedType: specifiedType, + serializedList: serializedList, + unhandled: unhandled, + result: result, + ); + return result.build(); + } +} + diff --git a/packages/holzleitner_api/lib/src/model/contact_channel.g.dart b/packages/holzleitner_api/lib/src/model/contact_channel.g.dart new file mode 100644 index 0000000..2ac8df2 --- /dev/null +++ b/packages/holzleitner_api/lib/src/model/contact_channel.g.dart @@ -0,0 +1,146 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'contact_channel.dart'; + +// ************************************************************************** +// BuiltValueGenerator +// ************************************************************************** + +class _$ContactChannel extends ContactChannel { + @override + final String id; + @override + final ContactKind kind; + @override + final int position; + @override + final String sourceId; + @override + final String value; + + factory _$ContactChannel([void Function(ContactChannelBuilder)? updates]) => + (ContactChannelBuilder()..update(updates))._build(); + + _$ContactChannel._( + {required this.id, + required this.kind, + required this.position, + required this.sourceId, + required this.value}) + : super._(); + @override + ContactChannel rebuild(void Function(ContactChannelBuilder) updates) => + (toBuilder()..update(updates)).build(); + + @override + ContactChannelBuilder toBuilder() => ContactChannelBuilder()..replace(this); + + @override + bool operator ==(Object other) { + if (identical(other, this)) return true; + return other is ContactChannel && + id == other.id && + kind == other.kind && + position == other.position && + sourceId == other.sourceId && + value == other.value; + } + + @override + int get hashCode { + var _$hash = 0; + _$hash = $jc(_$hash, id.hashCode); + _$hash = $jc(_$hash, kind.hashCode); + _$hash = $jc(_$hash, position.hashCode); + _$hash = $jc(_$hash, sourceId.hashCode); + _$hash = $jc(_$hash, value.hashCode); + _$hash = $jf(_$hash); + return _$hash; + } + + @override + String toString() { + return (newBuiltValueToStringHelper(r'ContactChannel') + ..add('id', id) + ..add('kind', kind) + ..add('position', position) + ..add('sourceId', sourceId) + ..add('value', value)) + .toString(); + } +} + +class ContactChannelBuilder + implements Builder { + _$ContactChannel? _$v; + + String? _id; + String? get id => _$this._id; + set id(String? id) => _$this._id = id; + + ContactKind? _kind; + ContactKind? get kind => _$this._kind; + set kind(ContactKind? kind) => _$this._kind = kind; + + int? _position; + int? get position => _$this._position; + set position(int? position) => _$this._position = position; + + String? _sourceId; + String? get sourceId => _$this._sourceId; + set sourceId(String? sourceId) => _$this._sourceId = sourceId; + + String? _value; + String? get value => _$this._value; + set value(String? value) => _$this._value = value; + + ContactChannelBuilder() { + ContactChannel._defaults(this); + } + + ContactChannelBuilder get _$this { + final $v = _$v; + if ($v != null) { + _id = $v.id; + _kind = $v.kind; + _position = $v.position; + _sourceId = $v.sourceId; + _value = $v.value; + _$v = null; + } + return this; + } + + @override + void replace(ContactChannel other) { + _$v = other as _$ContactChannel; + } + + @override + void update(void Function(ContactChannelBuilder)? updates) { + if (updates != null) updates(this); + } + + @override + ContactChannel build() => _build(); + + _$ContactChannel _build() { + final _$result = _$v ?? + _$ContactChannel._( + id: BuiltValueNullFieldError.checkNotNull( + id, r'ContactChannel', 'id'), + kind: BuiltValueNullFieldError.checkNotNull( + kind, r'ContactChannel', 'kind'), + position: BuiltValueNullFieldError.checkNotNull( + position, r'ContactChannel', 'position'), + sourceId: BuiltValueNullFieldError.checkNotNull( + sourceId, r'ContactChannel', 'sourceId'), + value: BuiltValueNullFieldError.checkNotNull( + value, r'ContactChannel', 'value'), + ); + replace(_$result); + return _$result; + } +} + +// ignore_for_file: deprecated_member_use_from_same_package,type=lint diff --git a/packages/holzleitner_api/lib/src/model/contact_kind.dart b/packages/holzleitner_api/lib/src/model/contact_kind.dart new file mode 100644 index 0000000..c297594 --- /dev/null +++ b/packages/holzleitner_api/lib/src/model/contact_kind.dart @@ -0,0 +1,42 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// + +// ignore_for_file: unused_element +import 'package:built_collection/built_collection.dart'; +import 'package:built_value/built_value.dart'; +import 'package:built_value/serializer.dart'; + +part 'contact_kind.g.dart'; + +class ContactKind extends EnumClass { + + /// Art eines Kommunikationskanals. `fax` bewusst nicht mitgeführt — in der App nicht verwendet. + @BuiltValueEnumConst(wireName: r'phone') + static const ContactKind phone = _$phone; + /// Art eines Kommunikationskanals. `fax` bewusst nicht mitgeführt — in der App nicht verwendet. + @BuiltValueEnumConst(wireName: r'mobile') + static const ContactKind mobile = _$mobile; + /// Art eines Kommunikationskanals. `fax` bewusst nicht mitgeführt — in der App nicht verwendet. + @BuiltValueEnumConst(wireName: r'email') + static const ContactKind email = _$email; + /// Art eines Kommunikationskanals. `fax` bewusst nicht mitgeführt — in der App nicht verwendet. + @BuiltValueEnumConst(wireName: r'web') + static const ContactKind web = _$web; + + static Serializer get serializer => _$contactKindSerializer; + + const ContactKind._(String name): super(name); + + static BuiltSet get values => _$values; + static ContactKind valueOf(String name) => _$valueOf(name); +} + +/// Optionally, enum_class can generate a mixin to go with your enum for use +/// with Angular. It exposes your enum constants as getters. So, if you mix it +/// in to your Dart component class, the values become available to the +/// corresponding Angular template. +/// +/// Trigger mixin generation by writing a line like this one next to your enum. +abstract class ContactKindMixin = Object with _$ContactKindMixin; + diff --git a/packages/holzleitner_api/lib/src/model/contact_kind.g.dart b/packages/holzleitner_api/lib/src/model/contact_kind.g.dart new file mode 100644 index 0000000..c10e6c9 --- /dev/null +++ b/packages/holzleitner_api/lib/src/model/contact_kind.g.dart @@ -0,0 +1,85 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'contact_kind.dart'; + +// ************************************************************************** +// BuiltValueGenerator +// ************************************************************************** + +const ContactKind _$phone = const ContactKind._('phone'); +const ContactKind _$mobile = const ContactKind._('mobile'); +const ContactKind _$email = const ContactKind._('email'); +const ContactKind _$web = const ContactKind._('web'); + +ContactKind _$valueOf(String name) { + switch (name) { + case 'phone': + return _$phone; + case 'mobile': + return _$mobile; + case 'email': + return _$email; + case 'web': + return _$web; + default: + throw ArgumentError(name); + } +} + +final BuiltSet _$values = + BuiltSet(const [ + _$phone, + _$mobile, + _$email, + _$web, +]); + +class _$ContactKindMeta { + const _$ContactKindMeta(); + ContactKind get phone => _$phone; + ContactKind get mobile => _$mobile; + ContactKind get email => _$email; + ContactKind get web => _$web; + ContactKind valueOf(String name) => _$valueOf(name); + BuiltSet get values => _$values; +} + +abstract class _$ContactKindMixin { + // ignore: non_constant_identifier_names + _$ContactKindMeta get ContactKind => const _$ContactKindMeta(); +} + +Serializer _$contactKindSerializer = _$ContactKindSerializer(); + +class _$ContactKindSerializer implements PrimitiveSerializer { + static const Map _toWire = const { + 'phone': 'phone', + 'mobile': 'mobile', + 'email': 'email', + 'web': 'web', + }; + static const Map _fromWire = const { + 'phone': 'phone', + 'mobile': 'mobile', + 'email': 'email', + 'web': 'web', + }; + + @override + final Iterable types = const [ContactKind]; + @override + final String wireName = 'ContactKind'; + + @override + Object serialize(Serializers serializers, ContactKind object, + {FullType specifiedType = FullType.unspecified}) => + _toWire[object.name] ?? object.name; + + @override + ContactKind deserialize(Serializers serializers, Object serialized, + {FullType specifiedType = FullType.unspecified}) => + ContactKind.valueOf( + _fromWire[serialized] ?? (serialized is String ? serialized : '')); +} + +// ignore_for_file: deprecated_member_use_from_same_package,type=lint diff --git a/packages/holzleitner_api/lib/src/model/contact_role.dart b/packages/holzleitner_api/lib/src/model/contact_role.dart new file mode 100644 index 0000000..935aa2a --- /dev/null +++ b/packages/holzleitner_api/lib/src/model/contact_role.dart @@ -0,0 +1,45 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// + +// ignore_for_file: unused_element +import 'package:built_collection/built_collection.dart'; +import 'package:built_value/built_value.dart'; +import 'package:built_value/serializer.dart'; + +part 'contact_role.g.dart'; + +class ContactRole extends EnumClass { + + /// Rolle, mit der ein Kontakt-Datensatz an einer Lieferung hängt. Spiegelt die fünf Adress-FKs von `Belegkopf` (bzw. den Umweg über den Kunden): `header` = Belegadresse, `delivery` = Lieferadresse, `billing` = Rechnungsadresse, `contact_person` = Ansprechpartner, `customer_master` = Stammadresse des Kunden über `Kunden.AdressId`. + @BuiltValueEnumConst(wireName: r'header') + static const ContactRole header = _$header; + /// Rolle, mit der ein Kontakt-Datensatz an einer Lieferung hängt. Spiegelt die fünf Adress-FKs von `Belegkopf` (bzw. den Umweg über den Kunden): `header` = Belegadresse, `delivery` = Lieferadresse, `billing` = Rechnungsadresse, `contact_person` = Ansprechpartner, `customer_master` = Stammadresse des Kunden über `Kunden.AdressId`. + @BuiltValueEnumConst(wireName: r'delivery') + static const ContactRole delivery = _$delivery; + /// Rolle, mit der ein Kontakt-Datensatz an einer Lieferung hängt. Spiegelt die fünf Adress-FKs von `Belegkopf` (bzw. den Umweg über den Kunden): `header` = Belegadresse, `delivery` = Lieferadresse, `billing` = Rechnungsadresse, `contact_person` = Ansprechpartner, `customer_master` = Stammadresse des Kunden über `Kunden.AdressId`. + @BuiltValueEnumConst(wireName: r'billing') + static const ContactRole billing = _$billing; + /// Rolle, mit der ein Kontakt-Datensatz an einer Lieferung hängt. Spiegelt die fünf Adress-FKs von `Belegkopf` (bzw. den Umweg über den Kunden): `header` = Belegadresse, `delivery` = Lieferadresse, `billing` = Rechnungsadresse, `contact_person` = Ansprechpartner, `customer_master` = Stammadresse des Kunden über `Kunden.AdressId`. + @BuiltValueEnumConst(wireName: r'contact_person') + static const ContactRole contactPerson = _$contactPerson; + /// Rolle, mit der ein Kontakt-Datensatz an einer Lieferung hängt. Spiegelt die fünf Adress-FKs von `Belegkopf` (bzw. den Umweg über den Kunden): `header` = Belegadresse, `delivery` = Lieferadresse, `billing` = Rechnungsadresse, `contact_person` = Ansprechpartner, `customer_master` = Stammadresse des Kunden über `Kunden.AdressId`. + @BuiltValueEnumConst(wireName: r'customer_master') + static const ContactRole customerMaster = _$customerMaster; + + static Serializer get serializer => _$contactRoleSerializer; + + const ContactRole._(String name): super(name); + + static BuiltSet get values => _$values; + static ContactRole valueOf(String name) => _$valueOf(name); +} + +/// Optionally, enum_class can generate a mixin to go with your enum for use +/// with Angular. It exposes your enum constants as getters. So, if you mix it +/// in to your Dart component class, the values become available to the +/// corresponding Angular template. +/// +/// Trigger mixin generation by writing a line like this one next to your enum. +abstract class ContactRoleMixin = Object with _$ContactRoleMixin; + diff --git a/packages/holzleitner_api/lib/src/model/contact_role.g.dart b/packages/holzleitner_api/lib/src/model/contact_role.g.dart new file mode 100644 index 0000000..487223b --- /dev/null +++ b/packages/holzleitner_api/lib/src/model/contact_role.g.dart @@ -0,0 +1,92 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'contact_role.dart'; + +// ************************************************************************** +// BuiltValueGenerator +// ************************************************************************** + +const ContactRole _$header = const ContactRole._('header'); +const ContactRole _$delivery = const ContactRole._('delivery'); +const ContactRole _$billing = const ContactRole._('billing'); +const ContactRole _$contactPerson = const ContactRole._('contactPerson'); +const ContactRole _$customerMaster = const ContactRole._('customerMaster'); + +ContactRole _$valueOf(String name) { + switch (name) { + case 'header': + return _$header; + case 'delivery': + return _$delivery; + case 'billing': + return _$billing; + case 'contactPerson': + return _$contactPerson; + case 'customerMaster': + return _$customerMaster; + default: + throw ArgumentError(name); + } +} + +final BuiltSet _$values = + BuiltSet(const [ + _$header, + _$delivery, + _$billing, + _$contactPerson, + _$customerMaster, +]); + +class _$ContactRoleMeta { + const _$ContactRoleMeta(); + ContactRole get header => _$header; + ContactRole get delivery => _$delivery; + ContactRole get billing => _$billing; + ContactRole get contactPerson => _$contactPerson; + ContactRole get customerMaster => _$customerMaster; + ContactRole valueOf(String name) => _$valueOf(name); + BuiltSet get values => _$values; +} + +abstract class _$ContactRoleMixin { + // ignore: non_constant_identifier_names + _$ContactRoleMeta get ContactRole => const _$ContactRoleMeta(); +} + +Serializer _$contactRoleSerializer = _$ContactRoleSerializer(); + +class _$ContactRoleSerializer implements PrimitiveSerializer { + static const Map _toWire = const { + 'header': 'header', + 'delivery': 'delivery', + 'billing': 'billing', + 'contactPerson': 'contact_person', + 'customerMaster': 'customer_master', + }; + static const Map _fromWire = const { + 'header': 'header', + 'delivery': 'delivery', + 'billing': 'billing', + 'contact_person': 'contactPerson', + 'customer_master': 'customerMaster', + }; + + @override + final Iterable types = const [ContactRole]; + @override + final String wireName = 'ContactRole'; + + @override + Object serialize(Serializers serializers, ContactRole object, + {FullType specifiedType = FullType.unspecified}) => + _toWire[object.name] ?? object.name; + + @override + ContactRole deserialize(Serializers serializers, Object serialized, + {FullType specifiedType = FullType.unspecified}) => + ContactRole.valueOf( + _fromWire[serialized] ?? (serialized is String ? serialized : '')); +} + +// ignore_for_file: deprecated_member_use_from_same_package,type=lint diff --git a/packages/holzleitner_api/lib/src/model/contact_source.dart b/packages/holzleitner_api/lib/src/model/contact_source.dart new file mode 100644 index 0000000..14086e3 --- /dev/null +++ b/packages/holzleitner_api/lib/src/model/contact_source.dart @@ -0,0 +1,273 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// + +// ignore_for_file: unused_element +import 'package:holzleitner_api/src/model/contact_role.dart'; +import 'package:built_value/built_value.dart'; +import 'package:built_value/serializer.dart'; + +part 'contact_source.g.dart'; + +/// Snapshot eines ERP-Adress-Datensatzes, der zum Zeitpunkt des Tour-Syncs an einer Lieferung hing — Namensblock ohne Anschrift, weil die Adresse ihrerseits schon im Lieferungs-Snapshot steckt (`snap_*`-Spalten). Die eigentlichen Telefonnummern, E-Mails etc. liegen in den zugehörigen [`ContactChannel`]s. +/// +/// Properties: +/// * [abteilung] +/// * [anrede] +/// * [deliveryId] +/// * [funktion] +/// * [id] +/// * [name1] +/// * [name2] +/// * [name3] +/// * [role] +/// * [titel] +@BuiltValue() +abstract class ContactSource implements Built { + @BuiltValueField(wireName: r'abteilung') + String? get abteilung; + + @BuiltValueField(wireName: r'anrede') + String? get anrede; + + @BuiltValueField(wireName: r'deliveryId') + String get deliveryId; + + @BuiltValueField(wireName: r'funktion') + String? get funktion; + + @BuiltValueField(wireName: r'id') + String get id; + + @BuiltValueField(wireName: r'name1') + String? get name1; + + @BuiltValueField(wireName: r'name2') + String? get name2; + + @BuiltValueField(wireName: r'name3') + String? get name3; + + @BuiltValueField(wireName: r'role') + ContactRole get role; + // enum roleEnum { header, delivery, billing, contact_person, customer_master, }; + + @BuiltValueField(wireName: r'titel') + String? get titel; + + ContactSource._(); + + factory ContactSource([void updates(ContactSourceBuilder b)]) = _$ContactSource; + + @BuiltValueHook(initializeBuilder: true) + static void _defaults(ContactSourceBuilder b) => b; + + @BuiltValueSerializer(custom: true) + static Serializer get serializer => _$ContactSourceSerializer(); +} + +class _$ContactSourceSerializer implements PrimitiveSerializer { + @override + final Iterable types = const [ContactSource, _$ContactSource]; + + @override + final String wireName = r'ContactSource'; + + Iterable _serializeProperties( + Serializers serializers, + ContactSource object, { + FullType specifiedType = FullType.unspecified, + }) sync* { + if (object.abteilung != null) { + yield r'abteilung'; + yield serializers.serialize( + object.abteilung, + specifiedType: const FullType.nullable(String), + ); + } + if (object.anrede != null) { + yield r'anrede'; + yield serializers.serialize( + object.anrede, + specifiedType: const FullType.nullable(String), + ); + } + yield r'deliveryId'; + yield serializers.serialize( + object.deliveryId, + specifiedType: const FullType(String), + ); + if (object.funktion != null) { + yield r'funktion'; + yield serializers.serialize( + object.funktion, + specifiedType: const FullType.nullable(String), + ); + } + yield r'id'; + yield serializers.serialize( + object.id, + specifiedType: const FullType(String), + ); + if (object.name1 != null) { + yield r'name1'; + yield serializers.serialize( + object.name1, + specifiedType: const FullType.nullable(String), + ); + } + if (object.name2 != null) { + yield r'name2'; + yield serializers.serialize( + object.name2, + specifiedType: const FullType.nullable(String), + ); + } + if (object.name3 != null) { + yield r'name3'; + yield serializers.serialize( + object.name3, + specifiedType: const FullType.nullable(String), + ); + } + yield r'role'; + yield serializers.serialize( + object.role, + specifiedType: const FullType(ContactRole), + ); + if (object.titel != null) { + yield r'titel'; + yield serializers.serialize( + object.titel, + specifiedType: const FullType.nullable(String), + ); + } + } + + @override + Object serialize( + Serializers serializers, + ContactSource object, { + FullType specifiedType = FullType.unspecified, + }) { + return _serializeProperties(serializers, object, specifiedType: specifiedType).toList(); + } + + void _deserializeProperties( + Serializers serializers, + Object serialized, { + FullType specifiedType = FullType.unspecified, + required List serializedList, + required ContactSourceBuilder result, + required List unhandled, + }) { + for (var i = 0; i < serializedList.length; i += 2) { + final key = serializedList[i] as String; + final value = serializedList[i + 1]; + switch (key) { + case r'abteilung': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType.nullable(String), + ) as String?; + if (valueDes == null) continue; + result.abteilung = valueDes; + break; + case r'anrede': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType.nullable(String), + ) as String?; + if (valueDes == null) continue; + result.anrede = valueDes; + break; + case r'deliveryId': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType(String), + ) as String; + result.deliveryId = valueDes; + break; + case r'funktion': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType.nullable(String), + ) as String?; + if (valueDes == null) continue; + result.funktion = valueDes; + break; + case r'id': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType(String), + ) as String; + result.id = valueDes; + break; + case r'name1': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType.nullable(String), + ) as String?; + if (valueDes == null) continue; + result.name1 = valueDes; + break; + case r'name2': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType.nullable(String), + ) as String?; + if (valueDes == null) continue; + result.name2 = valueDes; + break; + case r'name3': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType.nullable(String), + ) as String?; + if (valueDes == null) continue; + result.name3 = valueDes; + break; + case r'role': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType(ContactRole), + ) as ContactRole; + result.role = valueDes; + break; + case r'titel': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType.nullable(String), + ) as String?; + if (valueDes == null) continue; + result.titel = valueDes; + break; + default: + unhandled.add(key); + unhandled.add(value); + break; + } + } + } + + @override + ContactSource deserialize( + Serializers serializers, + Object serialized, { + FullType specifiedType = FullType.unspecified, + }) { + final result = ContactSourceBuilder(); + final serializedList = (serialized as Iterable).toList(); + final unhandled = []; + _deserializeProperties( + serializers, + serialized, + specifiedType: specifiedType, + serializedList: serializedList, + unhandled: unhandled, + result: result, + ); + return result.build(); + } +} + diff --git a/packages/holzleitner_api/lib/src/model/contact_source.g.dart b/packages/holzleitner_api/lib/src/model/contact_source.g.dart new file mode 100644 index 0000000..115158c --- /dev/null +++ b/packages/holzleitner_api/lib/src/model/contact_source.g.dart @@ -0,0 +1,203 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'contact_source.dart'; + +// ************************************************************************** +// BuiltValueGenerator +// ************************************************************************** + +class _$ContactSource extends ContactSource { + @override + final String? abteilung; + @override + final String? anrede; + @override + final String deliveryId; + @override + final String? funktion; + @override + final String id; + @override + final String? name1; + @override + final String? name2; + @override + final String? name3; + @override + final ContactRole role; + @override + final String? titel; + + factory _$ContactSource([void Function(ContactSourceBuilder)? updates]) => + (ContactSourceBuilder()..update(updates))._build(); + + _$ContactSource._( + {this.abteilung, + this.anrede, + required this.deliveryId, + this.funktion, + required this.id, + this.name1, + this.name2, + this.name3, + required this.role, + this.titel}) + : super._(); + @override + ContactSource rebuild(void Function(ContactSourceBuilder) updates) => + (toBuilder()..update(updates)).build(); + + @override + ContactSourceBuilder toBuilder() => ContactSourceBuilder()..replace(this); + + @override + bool operator ==(Object other) { + if (identical(other, this)) return true; + return other is ContactSource && + abteilung == other.abteilung && + anrede == other.anrede && + deliveryId == other.deliveryId && + funktion == other.funktion && + id == other.id && + name1 == other.name1 && + name2 == other.name2 && + name3 == other.name3 && + role == other.role && + titel == other.titel; + } + + @override + int get hashCode { + var _$hash = 0; + _$hash = $jc(_$hash, abteilung.hashCode); + _$hash = $jc(_$hash, anrede.hashCode); + _$hash = $jc(_$hash, deliveryId.hashCode); + _$hash = $jc(_$hash, funktion.hashCode); + _$hash = $jc(_$hash, id.hashCode); + _$hash = $jc(_$hash, name1.hashCode); + _$hash = $jc(_$hash, name2.hashCode); + _$hash = $jc(_$hash, name3.hashCode); + _$hash = $jc(_$hash, role.hashCode); + _$hash = $jc(_$hash, titel.hashCode); + _$hash = $jf(_$hash); + return _$hash; + } + + @override + String toString() { + return (newBuiltValueToStringHelper(r'ContactSource') + ..add('abteilung', abteilung) + ..add('anrede', anrede) + ..add('deliveryId', deliveryId) + ..add('funktion', funktion) + ..add('id', id) + ..add('name1', name1) + ..add('name2', name2) + ..add('name3', name3) + ..add('role', role) + ..add('titel', titel)) + .toString(); + } +} + +class ContactSourceBuilder + implements Builder { + _$ContactSource? _$v; + + String? _abteilung; + String? get abteilung => _$this._abteilung; + set abteilung(String? abteilung) => _$this._abteilung = abteilung; + + String? _anrede; + String? get anrede => _$this._anrede; + set anrede(String? anrede) => _$this._anrede = anrede; + + String? _deliveryId; + String? get deliveryId => _$this._deliveryId; + set deliveryId(String? deliveryId) => _$this._deliveryId = deliveryId; + + String? _funktion; + String? get funktion => _$this._funktion; + set funktion(String? funktion) => _$this._funktion = funktion; + + String? _id; + String? get id => _$this._id; + set id(String? id) => _$this._id = id; + + String? _name1; + String? get name1 => _$this._name1; + set name1(String? name1) => _$this._name1 = name1; + + String? _name2; + String? get name2 => _$this._name2; + set name2(String? name2) => _$this._name2 = name2; + + String? _name3; + String? get name3 => _$this._name3; + set name3(String? name3) => _$this._name3 = name3; + + ContactRole? _role; + ContactRole? get role => _$this._role; + set role(ContactRole? role) => _$this._role = role; + + String? _titel; + String? get titel => _$this._titel; + set titel(String? titel) => _$this._titel = titel; + + ContactSourceBuilder() { + ContactSource._defaults(this); + } + + ContactSourceBuilder get _$this { + final $v = _$v; + if ($v != null) { + _abteilung = $v.abteilung; + _anrede = $v.anrede; + _deliveryId = $v.deliveryId; + _funktion = $v.funktion; + _id = $v.id; + _name1 = $v.name1; + _name2 = $v.name2; + _name3 = $v.name3; + _role = $v.role; + _titel = $v.titel; + _$v = null; + } + return this; + } + + @override + void replace(ContactSource other) { + _$v = other as _$ContactSource; + } + + @override + void update(void Function(ContactSourceBuilder)? updates) { + if (updates != null) updates(this); + } + + @override + ContactSource build() => _build(); + + _$ContactSource _build() { + final _$result = _$v ?? + _$ContactSource._( + abteilung: abteilung, + anrede: anrede, + deliveryId: BuiltValueNullFieldError.checkNotNull( + deliveryId, r'ContactSource', 'deliveryId'), + funktion: funktion, + id: BuiltValueNullFieldError.checkNotNull(id, r'ContactSource', 'id'), + name1: name1, + name2: name2, + name3: name3, + role: BuiltValueNullFieldError.checkNotNull( + role, r'ContactSource', 'role'), + titel: titel, + ); + replace(_$result); + return _$result; + } +} + +// ignore_for_file: deprecated_member_use_from_same_package,type=lint diff --git a/packages/holzleitner_api/lib/src/model/create_delivery_note_request.dart b/packages/holzleitner_api/lib/src/model/create_delivery_note_request.dart index 9088300..c01ae2d 100644 --- a/packages/holzleitner_api/lib/src/model/create_delivery_note_request.dart +++ b/packages/holzleitner_api/lib/src/model/create_delivery_note_request.dart @@ -12,7 +12,9 @@ part 'create_delivery_note_request.g.dart'; /// /// Properties: /// * [authorCarId] - Fahrzeug, das die Notiz erzeugt hat. Muss zum angemeldeten Account gehören. `None` ist erlaubt. +/// * [creditDeliveryItemId] - Optionaler Gutschrift-Bezug: die Belegzeile, für die diese Notiz als Gutschrift-Grund angelegt wird. Ermöglicht das gezielte Löschen beim Unremove. `None` für normale Notizen. /// * [imageAttachment] - Object-Storage-Key oder URL eines vorab hochgeladenen Bildes. +/// * [isAmountCreditNote] - `true` markiert die Notiz als Grund einer Betrags-Gutschrift (Lieferungs-Ebene). Default `false`. /// * [text] @BuiltValue() abstract class CreateDeliveryNoteRequest implements Built { @@ -20,10 +22,18 @@ abstract class CreateDeliveryNoteRequest implements Built _$this._authorCarId; set authorCarId(String? authorCarId) => _$this._authorCarId = authorCarId; + String? _creditDeliveryItemId; + String? get creditDeliveryItemId => _$this._creditDeliveryItemId; + set creditDeliveryItemId(String? creditDeliveryItemId) => + _$this._creditDeliveryItemId = creditDeliveryItemId; + String? _imageAttachment; String? get imageAttachment => _$this._imageAttachment; set imageAttachment(String? imageAttachment) => _$this._imageAttachment = imageAttachment; + bool? _isAmountCreditNote; + bool? get isAmountCreditNote => _$this._isAmountCreditNote; + set isAmountCreditNote(bool? isAmountCreditNote) => + _$this._isAmountCreditNote = isAmountCreditNote; + String? _text; String? get text => _$this._text; set text(String? text) => _$this._text = text; @@ -85,7 +109,9 @@ class CreateDeliveryNoteRequestBuilder final $v = _$v; if ($v != null) { _authorCarId = $v.authorCarId; + _creditDeliveryItemId = $v.creditDeliveryItemId; _imageAttachment = $v.imageAttachment; + _isAmountCreditNote = $v.isAmountCreditNote; _text = $v.text; _$v = null; } @@ -109,7 +135,9 @@ class CreateDeliveryNoteRequestBuilder final _$result = _$v ?? _$CreateDeliveryNoteRequest._( authorCarId: authorCarId, + creditDeliveryItemId: creditDeliveryItemId, imageAttachment: imageAttachment, + isAmountCreditNote: isAmountCreditNote, text: text, ); replace(_$result); diff --git a/packages/holzleitner_api/lib/src/model/create_payment_method_request.dart b/packages/holzleitner_api/lib/src/model/create_payment_method_request.dart new file mode 100644 index 0000000..b14933d --- /dev/null +++ b/packages/holzleitner_api/lib/src/model/create_payment_method_request.dart @@ -0,0 +1,124 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// + +// ignore_for_file: unused_element +import 'package:built_value/built_value.dart'; +import 'package:built_value/serializer.dart'; + +part 'create_payment_method_request.g.dart'; + +/// CreatePaymentMethodRequest +/// +/// Properties: +/// * [code] - Eindeutiger Programm-Identifier (z. B. `\"paypal\"`, `\"klarna\"`). +/// * [name] - Anzeige-Name in der UI. +@BuiltValue() +abstract class CreatePaymentMethodRequest implements Built { + /// Eindeutiger Programm-Identifier (z. B. `\"paypal\"`, `\"klarna\"`). + @BuiltValueField(wireName: r'code') + String get code; + + /// Anzeige-Name in der UI. + @BuiltValueField(wireName: r'name') + String get name; + + CreatePaymentMethodRequest._(); + + factory CreatePaymentMethodRequest([void updates(CreatePaymentMethodRequestBuilder b)]) = _$CreatePaymentMethodRequest; + + @BuiltValueHook(initializeBuilder: true) + static void _defaults(CreatePaymentMethodRequestBuilder b) => b; + + @BuiltValueSerializer(custom: true) + static Serializer get serializer => _$CreatePaymentMethodRequestSerializer(); +} + +class _$CreatePaymentMethodRequestSerializer implements PrimitiveSerializer { + @override + final Iterable types = const [CreatePaymentMethodRequest, _$CreatePaymentMethodRequest]; + + @override + final String wireName = r'CreatePaymentMethodRequest'; + + Iterable _serializeProperties( + Serializers serializers, + CreatePaymentMethodRequest object, { + FullType specifiedType = FullType.unspecified, + }) sync* { + yield r'code'; + yield serializers.serialize( + object.code, + specifiedType: const FullType(String), + ); + yield r'name'; + yield serializers.serialize( + object.name, + specifiedType: const FullType(String), + ); + } + + @override + Object serialize( + Serializers serializers, + CreatePaymentMethodRequest object, { + FullType specifiedType = FullType.unspecified, + }) { + return _serializeProperties(serializers, object, specifiedType: specifiedType).toList(); + } + + void _deserializeProperties( + Serializers serializers, + Object serialized, { + FullType specifiedType = FullType.unspecified, + required List serializedList, + required CreatePaymentMethodRequestBuilder result, + required List unhandled, + }) { + for (var i = 0; i < serializedList.length; i += 2) { + final key = serializedList[i] as String; + final value = serializedList[i + 1]; + switch (key) { + case r'code': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType(String), + ) as String; + result.code = valueDes; + break; + case r'name': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType(String), + ) as String; + result.name = valueDes; + break; + default: + unhandled.add(key); + unhandled.add(value); + break; + } + } + } + + @override + CreatePaymentMethodRequest deserialize( + Serializers serializers, + Object serialized, { + FullType specifiedType = FullType.unspecified, + }) { + final result = CreatePaymentMethodRequestBuilder(); + final serializedList = (serialized as Iterable).toList(); + final unhandled = []; + _deserializeProperties( + serializers, + serialized, + specifiedType: specifiedType, + serializedList: serializedList, + unhandled: unhandled, + result: result, + ); + return result.build(); + } +} + diff --git a/packages/holzleitner_api/lib/src/model/create_payment_method_request.g.dart b/packages/holzleitner_api/lib/src/model/create_payment_method_request.g.dart new file mode 100644 index 0000000..03a1ec6 --- /dev/null +++ b/packages/holzleitner_api/lib/src/model/create_payment_method_request.g.dart @@ -0,0 +1,109 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'create_payment_method_request.dart'; + +// ************************************************************************** +// BuiltValueGenerator +// ************************************************************************** + +class _$CreatePaymentMethodRequest extends CreatePaymentMethodRequest { + @override + final String code; + @override + final String name; + + factory _$CreatePaymentMethodRequest( + [void Function(CreatePaymentMethodRequestBuilder)? updates]) => + (CreatePaymentMethodRequestBuilder()..update(updates))._build(); + + _$CreatePaymentMethodRequest._({required this.code, required this.name}) + : super._(); + @override + CreatePaymentMethodRequest rebuild( + void Function(CreatePaymentMethodRequestBuilder) updates) => + (toBuilder()..update(updates)).build(); + + @override + CreatePaymentMethodRequestBuilder toBuilder() => + CreatePaymentMethodRequestBuilder()..replace(this); + + @override + bool operator ==(Object other) { + if (identical(other, this)) return true; + return other is CreatePaymentMethodRequest && + code == other.code && + name == other.name; + } + + @override + int get hashCode { + var _$hash = 0; + _$hash = $jc(_$hash, code.hashCode); + _$hash = $jc(_$hash, name.hashCode); + _$hash = $jf(_$hash); + return _$hash; + } + + @override + String toString() { + return (newBuiltValueToStringHelper(r'CreatePaymentMethodRequest') + ..add('code', code) + ..add('name', name)) + .toString(); + } +} + +class CreatePaymentMethodRequestBuilder + implements + Builder { + _$CreatePaymentMethodRequest? _$v; + + String? _code; + String? get code => _$this._code; + set code(String? code) => _$this._code = code; + + String? _name; + String? get name => _$this._name; + set name(String? name) => _$this._name = name; + + CreatePaymentMethodRequestBuilder() { + CreatePaymentMethodRequest._defaults(this); + } + + CreatePaymentMethodRequestBuilder get _$this { + final $v = _$v; + if ($v != null) { + _code = $v.code; + _name = $v.name; + _$v = null; + } + return this; + } + + @override + void replace(CreatePaymentMethodRequest other) { + _$v = other as _$CreatePaymentMethodRequest; + } + + @override + void update(void Function(CreatePaymentMethodRequestBuilder)? updates) { + if (updates != null) updates(this); + } + + @override + CreatePaymentMethodRequest build() => _build(); + + _$CreatePaymentMethodRequest _build() { + final _$result = _$v ?? + _$CreatePaymentMethodRequest._( + code: BuiltValueNullFieldError.checkNotNull( + code, r'CreatePaymentMethodRequest', 'code'), + name: BuiltValueNullFieldError.checkNotNull( + name, r'CreatePaymentMethodRequest', 'name'), + ); + replace(_$result); + return _$result; + } +} + +// ignore_for_file: deprecated_member_use_from_same_package,type=lint diff --git a/packages/holzleitner_api/lib/src/model/create_service_request.dart b/packages/holzleitner_api/lib/src/model/create_service_request.dart new file mode 100644 index 0000000..0f8eab1 --- /dev/null +++ b/packages/holzleitner_api/lib/src/model/create_service_request.dart @@ -0,0 +1,199 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// + +// ignore_for_file: unused_element +import 'package:holzleitner_api/src/model/service_kind.dart'; +import 'package:built_value/built_value.dart'; +import 'package:built_value/serializer.dart'; + +part 'create_service_request.g.dart'; + +/// CreateServiceRequest +/// +/// Properties: +/// * [key] - Eindeutiger Programm-Identifier (z. B. `\"podium_setup\"`). +/// * [kind] +/// * [maxValue] +/// * [minValue] - Nur bei `Numeric` sinnvoll. +/// * [name] +/// * [sortOrder] +@BuiltValue() +abstract class CreateServiceRequest implements Built { + /// Eindeutiger Programm-Identifier (z. B. `\"podium_setup\"`). + @BuiltValueField(wireName: r'key') + String get key; + + @BuiltValueField(wireName: r'kind') + ServiceKind get kind; + // enum kindEnum { boolean, numeric, }; + + @BuiltValueField(wireName: r'maxValue') + int? get maxValue; + + /// Nur bei `Numeric` sinnvoll. + @BuiltValueField(wireName: r'minValue') + int? get minValue; + + @BuiltValueField(wireName: r'name') + String get name; + + @BuiltValueField(wireName: r'sortOrder') + int? get sortOrder; + + CreateServiceRequest._(); + + factory CreateServiceRequest([void updates(CreateServiceRequestBuilder b)]) = _$CreateServiceRequest; + + @BuiltValueHook(initializeBuilder: true) + static void _defaults(CreateServiceRequestBuilder b) => b; + + @BuiltValueSerializer(custom: true) + static Serializer get serializer => _$CreateServiceRequestSerializer(); +} + +class _$CreateServiceRequestSerializer implements PrimitiveSerializer { + @override + final Iterable types = const [CreateServiceRequest, _$CreateServiceRequest]; + + @override + final String wireName = r'CreateServiceRequest'; + + Iterable _serializeProperties( + Serializers serializers, + CreateServiceRequest object, { + FullType specifiedType = FullType.unspecified, + }) sync* { + yield r'key'; + yield serializers.serialize( + object.key, + specifiedType: const FullType(String), + ); + yield r'kind'; + yield serializers.serialize( + object.kind, + specifiedType: const FullType(ServiceKind), + ); + if (object.maxValue != null) { + yield r'maxValue'; + yield serializers.serialize( + object.maxValue, + specifiedType: const FullType.nullable(int), + ); + } + if (object.minValue != null) { + yield r'minValue'; + yield serializers.serialize( + object.minValue, + specifiedType: const FullType.nullable(int), + ); + } + yield r'name'; + yield serializers.serialize( + object.name, + specifiedType: const FullType(String), + ); + if (object.sortOrder != null) { + yield r'sortOrder'; + yield serializers.serialize( + object.sortOrder, + specifiedType: const FullType.nullable(int), + ); + } + } + + @override + Object serialize( + Serializers serializers, + CreateServiceRequest object, { + FullType specifiedType = FullType.unspecified, + }) { + return _serializeProperties(serializers, object, specifiedType: specifiedType).toList(); + } + + void _deserializeProperties( + Serializers serializers, + Object serialized, { + FullType specifiedType = FullType.unspecified, + required List serializedList, + required CreateServiceRequestBuilder result, + required List unhandled, + }) { + for (var i = 0; i < serializedList.length; i += 2) { + final key = serializedList[i] as String; + final value = serializedList[i + 1]; + switch (key) { + case r'key': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType(String), + ) as String; + result.key = valueDes; + break; + case r'kind': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType(ServiceKind), + ) as ServiceKind; + result.kind = valueDes; + break; + case r'maxValue': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType.nullable(int), + ) as int?; + if (valueDes == null) continue; + result.maxValue = valueDes; + break; + case r'minValue': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType.nullable(int), + ) as int?; + if (valueDes == null) continue; + result.minValue = valueDes; + break; + case r'name': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType(String), + ) as String; + result.name = valueDes; + break; + case r'sortOrder': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType.nullable(int), + ) as int?; + if (valueDes == null) continue; + result.sortOrder = valueDes; + break; + default: + unhandled.add(key); + unhandled.add(value); + break; + } + } + } + + @override + CreateServiceRequest deserialize( + Serializers serializers, + Object serialized, { + FullType specifiedType = FullType.unspecified, + }) { + final result = CreateServiceRequestBuilder(); + final serializedList = (serialized as Iterable).toList(); + final unhandled = []; + _deserializeProperties( + serializers, + serialized, + specifiedType: specifiedType, + serializedList: serializedList, + unhandled: unhandled, + result: result, + ); + return result.build(); + } +} + diff --git a/packages/holzleitner_api/lib/src/model/create_service_request.g.dart b/packages/holzleitner_api/lib/src/model/create_service_request.g.dart new file mode 100644 index 0000000..1fc44c9 --- /dev/null +++ b/packages/holzleitner_api/lib/src/model/create_service_request.g.dart @@ -0,0 +1,159 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'create_service_request.dart'; + +// ************************************************************************** +// BuiltValueGenerator +// ************************************************************************** + +class _$CreateServiceRequest extends CreateServiceRequest { + @override + final String key; + @override + final ServiceKind kind; + @override + final int? maxValue; + @override + final int? minValue; + @override + final String name; + @override + final int? sortOrder; + + factory _$CreateServiceRequest( + [void Function(CreateServiceRequestBuilder)? updates]) => + (CreateServiceRequestBuilder()..update(updates))._build(); + + _$CreateServiceRequest._( + {required this.key, + required this.kind, + this.maxValue, + this.minValue, + required this.name, + this.sortOrder}) + : super._(); + @override + CreateServiceRequest rebuild( + void Function(CreateServiceRequestBuilder) updates) => + (toBuilder()..update(updates)).build(); + + @override + CreateServiceRequestBuilder toBuilder() => + CreateServiceRequestBuilder()..replace(this); + + @override + bool operator ==(Object other) { + if (identical(other, this)) return true; + return other is CreateServiceRequest && + key == other.key && + kind == other.kind && + maxValue == other.maxValue && + minValue == other.minValue && + name == other.name && + sortOrder == other.sortOrder; + } + + @override + int get hashCode { + var _$hash = 0; + _$hash = $jc(_$hash, key.hashCode); + _$hash = $jc(_$hash, kind.hashCode); + _$hash = $jc(_$hash, maxValue.hashCode); + _$hash = $jc(_$hash, minValue.hashCode); + _$hash = $jc(_$hash, name.hashCode); + _$hash = $jc(_$hash, sortOrder.hashCode); + _$hash = $jf(_$hash); + return _$hash; + } + + @override + String toString() { + return (newBuiltValueToStringHelper(r'CreateServiceRequest') + ..add('key', key) + ..add('kind', kind) + ..add('maxValue', maxValue) + ..add('minValue', minValue) + ..add('name', name) + ..add('sortOrder', sortOrder)) + .toString(); + } +} + +class CreateServiceRequestBuilder + implements Builder { + _$CreateServiceRequest? _$v; + + String? _key; + String? get key => _$this._key; + set key(String? key) => _$this._key = key; + + ServiceKind? _kind; + ServiceKind? get kind => _$this._kind; + set kind(ServiceKind? kind) => _$this._kind = kind; + + int? _maxValue; + int? get maxValue => _$this._maxValue; + set maxValue(int? maxValue) => _$this._maxValue = maxValue; + + int? _minValue; + int? get minValue => _$this._minValue; + set minValue(int? minValue) => _$this._minValue = minValue; + + String? _name; + String? get name => _$this._name; + set name(String? name) => _$this._name = name; + + int? _sortOrder; + int? get sortOrder => _$this._sortOrder; + set sortOrder(int? sortOrder) => _$this._sortOrder = sortOrder; + + CreateServiceRequestBuilder() { + CreateServiceRequest._defaults(this); + } + + CreateServiceRequestBuilder get _$this { + final $v = _$v; + if ($v != null) { + _key = $v.key; + _kind = $v.kind; + _maxValue = $v.maxValue; + _minValue = $v.minValue; + _name = $v.name; + _sortOrder = $v.sortOrder; + _$v = null; + } + return this; + } + + @override + void replace(CreateServiceRequest other) { + _$v = other as _$CreateServiceRequest; + } + + @override + void update(void Function(CreateServiceRequestBuilder)? updates) { + if (updates != null) updates(this); + } + + @override + CreateServiceRequest build() => _build(); + + _$CreateServiceRequest _build() { + final _$result = _$v ?? + _$CreateServiceRequest._( + key: BuiltValueNullFieldError.checkNotNull( + key, r'CreateServiceRequest', 'key'), + kind: BuiltValueNullFieldError.checkNotNull( + kind, r'CreateServiceRequest', 'kind'), + maxValue: maxValue, + minValue: minValue, + name: BuiltValueNullFieldError.checkNotNull( + name, r'CreateServiceRequest', 'name'), + sortOrder: sortOrder, + ); + replace(_$result); + return _$result; + } +} + +// ignore_for_file: deprecated_member_use_from_same_package,type=lint diff --git a/packages/holzleitner_api/lib/src/model/credit_action.dart b/packages/holzleitner_api/lib/src/model/credit_action.dart new file mode 100644 index 0000000..5edf642 --- /dev/null +++ b/packages/holzleitner_api/lib/src/model/credit_action.dart @@ -0,0 +1,36 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// + +// ignore_for_file: unused_element +import 'package:built_collection/built_collection.dart'; +import 'package:built_value/built_value.dart'; +import 'package:built_value/serializer.dart'; + +part 'credit_action.g.dart'; + +class CreditAction extends EnumClass { + + /// Art des Gutschrift-Ereignisses. + @BuiltValueEnumConst(wireName: r'set') + static const CreditAction set_ = _$set_; + /// Art des Gutschrift-Ereignisses. + @BuiltValueEnumConst(wireName: r'remove') + static const CreditAction remove = _$remove; + + static Serializer get serializer => _$creditActionSerializer; + + const CreditAction._(String name): super(name); + + static BuiltSet get values => _$values; + static CreditAction valueOf(String name) => _$valueOf(name); +} + +/// Optionally, enum_class can generate a mixin to go with your enum for use +/// with Angular. It exposes your enum constants as getters. So, if you mix it +/// in to your Dart component class, the values become available to the +/// corresponding Angular template. +/// +/// Trigger mixin generation by writing a line like this one next to your enum. +abstract class CreditActionMixin = Object with _$CreditActionMixin; + diff --git a/packages/holzleitner_api/lib/src/model/credit_action.g.dart b/packages/holzleitner_api/lib/src/model/credit_action.g.dart new file mode 100644 index 0000000..da357cb --- /dev/null +++ b/packages/holzleitner_api/lib/src/model/credit_action.g.dart @@ -0,0 +1,71 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'credit_action.dart'; + +// ************************************************************************** +// BuiltValueGenerator +// ************************************************************************** + +const CreditAction _$set_ = const CreditAction._('set_'); +const CreditAction _$remove = const CreditAction._('remove'); + +CreditAction _$valueOf(String name) { + switch (name) { + case 'set_': + return _$set_; + case 'remove': + return _$remove; + default: + throw ArgumentError(name); + } +} + +final BuiltSet _$values = + BuiltSet(const [ + _$set_, + _$remove, +]); + +class _$CreditActionMeta { + const _$CreditActionMeta(); + CreditAction get set_ => _$set_; + CreditAction get remove => _$remove; + CreditAction valueOf(String name) => _$valueOf(name); + BuiltSet get values => _$values; +} + +abstract class _$CreditActionMixin { + // ignore: non_constant_identifier_names + _$CreditActionMeta get CreditAction => const _$CreditActionMeta(); +} + +Serializer _$creditActionSerializer = _$CreditActionSerializer(); + +class _$CreditActionSerializer implements PrimitiveSerializer { + static const Map _toWire = const { + 'set_': 'set', + 'remove': 'remove', + }; + static const Map _fromWire = const { + 'set': 'set_', + 'remove': 'remove', + }; + + @override + final Iterable types = const [CreditAction]; + @override + final String wireName = 'CreditAction'; + + @override + Object serialize(Serializers serializers, CreditAction object, + {FullType specifiedType = FullType.unspecified}) => + _toWire[object.name] ?? object.name; + + @override + CreditAction deserialize(Serializers serializers, Object serialized, + {FullType specifiedType = FullType.unspecified}) => + CreditAction.valueOf( + _fromWire[serialized] ?? (serialized is String ? serialized : '')); +} + +// ignore_for_file: deprecated_member_use_from_same_package,type=lint diff --git a/packages/holzleitner_api/lib/src/model/delivered_belegnummern_response.dart b/packages/holzleitner_api/lib/src/model/delivered_belegnummern_response.dart new file mode 100644 index 0000000..6219d8d --- /dev/null +++ b/packages/holzleitner_api/lib/src/model/delivered_belegnummern_response.dart @@ -0,0 +1,142 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// + +// ignore_for_file: unused_element +import 'package:built_collection/built_collection.dart'; +import 'package:built_value/built_value.dart'; +import 'package:built_value/serializer.dart'; + +part 'delivered_belegnummern_response.g.dart'; + +/// DeliveredBelegnummernResponse +/// +/// Properties: +/// * [belegnummern] - Belegnummern aller **ausgelieferten** (abgeschlossenen) Lieferungen, deren Liefermail noch **nicht versendet** wurde, aufsteigend nach Abschluss-Zeitpunkt. +/// * [count] - Anzahl der offenen (noch nicht versendeten) Belege. +/// * [day] - Tag, nach dem gefiltert wurde (ISO `YYYY-MM-DD`), oder `\"all\"` wenn kein `day` angegeben war. +@BuiltValue() +abstract class DeliveredBelegnummernResponse implements Built { + /// Belegnummern aller **ausgelieferten** (abgeschlossenen) Lieferungen, deren Liefermail noch **nicht versendet** wurde, aufsteigend nach Abschluss-Zeitpunkt. + @BuiltValueField(wireName: r'belegnummern') + BuiltList get belegnummern; + + /// Anzahl der offenen (noch nicht versendeten) Belege. + @BuiltValueField(wireName: r'count') + int get count; + + /// Tag, nach dem gefiltert wurde (ISO `YYYY-MM-DD`), oder `\"all\"` wenn kein `day` angegeben war. + @BuiltValueField(wireName: r'day') + String get day; + + DeliveredBelegnummernResponse._(); + + factory DeliveredBelegnummernResponse([void updates(DeliveredBelegnummernResponseBuilder b)]) = _$DeliveredBelegnummernResponse; + + @BuiltValueHook(initializeBuilder: true) + static void _defaults(DeliveredBelegnummernResponseBuilder b) => b; + + @BuiltValueSerializer(custom: true) + static Serializer get serializer => _$DeliveredBelegnummernResponseSerializer(); +} + +class _$DeliveredBelegnummernResponseSerializer implements PrimitiveSerializer { + @override + final Iterable types = const [DeliveredBelegnummernResponse, _$DeliveredBelegnummernResponse]; + + @override + final String wireName = r'DeliveredBelegnummernResponse'; + + Iterable _serializeProperties( + Serializers serializers, + DeliveredBelegnummernResponse object, { + FullType specifiedType = FullType.unspecified, + }) sync* { + yield r'belegnummern'; + yield serializers.serialize( + object.belegnummern, + specifiedType: const FullType(BuiltList, [FullType(String)]), + ); + yield r'count'; + yield serializers.serialize( + object.count, + specifiedType: const FullType(int), + ); + yield r'day'; + yield serializers.serialize( + object.day, + specifiedType: const FullType(String), + ); + } + + @override + Object serialize( + Serializers serializers, + DeliveredBelegnummernResponse object, { + FullType specifiedType = FullType.unspecified, + }) { + return _serializeProperties(serializers, object, specifiedType: specifiedType).toList(); + } + + void _deserializeProperties( + Serializers serializers, + Object serialized, { + FullType specifiedType = FullType.unspecified, + required List serializedList, + required DeliveredBelegnummernResponseBuilder result, + required List unhandled, + }) { + for (var i = 0; i < serializedList.length; i += 2) { + final key = serializedList[i] as String; + final value = serializedList[i + 1]; + switch (key) { + case r'belegnummern': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType(BuiltList, [FullType(String)]), + ) as BuiltList; + result.belegnummern.replace(valueDes); + break; + case r'count': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType(int), + ) as int; + result.count = valueDes; + break; + case r'day': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType(String), + ) as String; + result.day = valueDes; + break; + default: + unhandled.add(key); + unhandled.add(value); + break; + } + } + } + + @override + DeliveredBelegnummernResponse deserialize( + Serializers serializers, + Object serialized, { + FullType specifiedType = FullType.unspecified, + }) { + final result = DeliveredBelegnummernResponseBuilder(); + final serializedList = (serialized as Iterable).toList(); + final unhandled = []; + _deserializeProperties( + serializers, + serialized, + specifiedType: specifiedType, + serializedList: serializedList, + unhandled: unhandled, + result: result, + ); + return result.build(); + } +} + diff --git a/packages/holzleitner_api/lib/src/model/delivered_belegnummern_response.g.dart b/packages/holzleitner_api/lib/src/model/delivered_belegnummern_response.g.dart new file mode 100644 index 0000000..1cea8b2 --- /dev/null +++ b/packages/holzleitner_api/lib/src/model/delivered_belegnummern_response.g.dart @@ -0,0 +1,137 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'delivered_belegnummern_response.dart'; + +// ************************************************************************** +// BuiltValueGenerator +// ************************************************************************** + +class _$DeliveredBelegnummernResponse extends DeliveredBelegnummernResponse { + @override + final BuiltList belegnummern; + @override + final int count; + @override + final String day; + + factory _$DeliveredBelegnummernResponse( + [void Function(DeliveredBelegnummernResponseBuilder)? updates]) => + (DeliveredBelegnummernResponseBuilder()..update(updates))._build(); + + _$DeliveredBelegnummernResponse._( + {required this.belegnummern, required this.count, required this.day}) + : super._(); + @override + DeliveredBelegnummernResponse rebuild( + void Function(DeliveredBelegnummernResponseBuilder) updates) => + (toBuilder()..update(updates)).build(); + + @override + DeliveredBelegnummernResponseBuilder toBuilder() => + DeliveredBelegnummernResponseBuilder()..replace(this); + + @override + bool operator ==(Object other) { + if (identical(other, this)) return true; + return other is DeliveredBelegnummernResponse && + belegnummern == other.belegnummern && + count == other.count && + day == other.day; + } + + @override + int get hashCode { + var _$hash = 0; + _$hash = $jc(_$hash, belegnummern.hashCode); + _$hash = $jc(_$hash, count.hashCode); + _$hash = $jc(_$hash, day.hashCode); + _$hash = $jf(_$hash); + return _$hash; + } + + @override + String toString() { + return (newBuiltValueToStringHelper(r'DeliveredBelegnummernResponse') + ..add('belegnummern', belegnummern) + ..add('count', count) + ..add('day', day)) + .toString(); + } +} + +class DeliveredBelegnummernResponseBuilder + implements + Builder { + _$DeliveredBelegnummernResponse? _$v; + + ListBuilder? _belegnummern; + ListBuilder get belegnummern => + _$this._belegnummern ??= ListBuilder(); + set belegnummern(ListBuilder? belegnummern) => + _$this._belegnummern = belegnummern; + + int? _count; + int? get count => _$this._count; + set count(int? count) => _$this._count = count; + + String? _day; + String? get day => _$this._day; + set day(String? day) => _$this._day = day; + + DeliveredBelegnummernResponseBuilder() { + DeliveredBelegnummernResponse._defaults(this); + } + + DeliveredBelegnummernResponseBuilder get _$this { + final $v = _$v; + if ($v != null) { + _belegnummern = $v.belegnummern.toBuilder(); + _count = $v.count; + _day = $v.day; + _$v = null; + } + return this; + } + + @override + void replace(DeliveredBelegnummernResponse other) { + _$v = other as _$DeliveredBelegnummernResponse; + } + + @override + void update(void Function(DeliveredBelegnummernResponseBuilder)? updates) { + if (updates != null) updates(this); + } + + @override + DeliveredBelegnummernResponse build() => _build(); + + _$DeliveredBelegnummernResponse _build() { + _$DeliveredBelegnummernResponse _$result; + try { + _$result = _$v ?? + _$DeliveredBelegnummernResponse._( + belegnummern: belegnummern.build(), + count: BuiltValueNullFieldError.checkNotNull( + count, r'DeliveredBelegnummernResponse', 'count'), + day: BuiltValueNullFieldError.checkNotNull( + day, r'DeliveredBelegnummernResponse', 'day'), + ); + } catch (_) { + late String _$failedField; + try { + _$failedField = 'belegnummern'; + belegnummern.build(); + } catch (e) { + throw BuiltValueNestedFieldError( + r'DeliveredBelegnummernResponse', _$failedField, e.toString()); + } + rethrow; + } + replace(_$result); + return _$result; + } +} + +// ignore_for_file: deprecated_member_use_from_same_package,type=lint diff --git a/packages/holzleitner_api/lib/src/model/delivery.dart b/packages/holzleitner_api/lib/src/model/delivery.dart index 412b53a..f45554f 100644 --- a/packages/holzleitner_api/lib/src/model/delivery.dart +++ b/packages/holzleitner_api/lib/src/model/delivery.dart @@ -22,6 +22,8 @@ part 'delivery.g.dart'; /// * [erpBelegartId] - ERP-Beleg-Bezug: business-stabiles Paar `(Belegart, Belegnummer)`. Überlebt den Belegkopf-Archivübergang. /// * [erpBelegnummer] /// * [id] +/// * [paymentMethodId] - Für den Restbetrag gewählte Zahlungsart — FK auf `payment_methods`. Vom Kunden bei Bestellung festgelegt, der Fahrer übernimmt nur die Abwicklung. Aktiv-Flag und Anzeige-Name werden über die Stammdaten-Tabelle aufgelöst, nicht hier embeddet. +/// * [prepaidAmount] - Bei Bestellung schon bezahlter Betrag in EUR. `0.0` wenn der Kunde alles bei Lieferung zahlt. Wird vom ERP-Sync gefüllt. /// * [specialAgreements] - Sondervereinbarungen (z. B. „Türklingel defekt, hintenrum klopfen\"). /// * [state] /// * [stateReason] - Begründung bei `state == Held` oder `state == Canceled`. Beim Resume / Complete wieder `None`. @@ -57,6 +59,14 @@ abstract class Delivery { @BuiltValueField(wireName: r'id') String get id; + /// Für den Restbetrag gewählte Zahlungsart — FK auf `payment_methods`. Vom Kunden bei Bestellung festgelegt, der Fahrer übernimmt nur die Abwicklung. Aktiv-Flag und Anzeige-Name werden über die Stammdaten-Tabelle aufgelöst, nicht hier embeddet. + @BuiltValueField(wireName: r'paymentMethodId') + String get paymentMethodId; + + /// Bei Bestellung schon bezahlter Betrag in EUR. `0.0` wenn der Kunde alles bei Lieferung zahlt. Wird vom ERP-Sync gefüllt. + @BuiltValueField(wireName: r'prepaidAmount') + double get prepaidAmount; + /// Sondervereinbarungen (z. B. „Türklingel defekt, hintenrum klopfen\"). @BuiltValueField(wireName: r'specialAgreements') String? get specialAgreements; @@ -132,6 +142,16 @@ class _$DeliverySerializer implements PrimitiveSerializer { object.id, specifiedType: const FullType(String), ); + yield r'paymentMethodId'; + yield serializers.serialize( + object.paymentMethodId, + specifiedType: const FullType(String), + ); + yield r'prepaidAmount'; + yield serializers.serialize( + object.prepaidAmount, + specifiedType: const FullType(double), + ); if (object.specialAgreements != null) { yield r'specialAgreements'; yield serializers.serialize( @@ -277,6 +297,20 @@ class _$$DeliverySerializer implements PrimitiveSerializer<$Delivery> { ) as String; result.id = valueDes; break; + case r'paymentMethodId': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType(String), + ) as String; + result.paymentMethodId = valueDes; + break; + case r'prepaidAmount': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType(double), + ) as double; + result.prepaidAmount = valueDes; + break; case r'specialAgreements': final valueDes = serializers.deserialize( value, diff --git a/packages/holzleitner_api/lib/src/model/delivery.g.dart b/packages/holzleitner_api/lib/src/model/delivery.g.dart index 2a8cb16..442b8c6 100644 --- a/packages/holzleitner_api/lib/src/model/delivery.g.dart +++ b/packages/holzleitner_api/lib/src/model/delivery.g.dart @@ -33,6 +33,12 @@ abstract class DeliveryBuilder { String? get id; set id(String? id); + String? get paymentMethodId; + set paymentMethodId(String? paymentMethodId); + + double? get prepaidAmount; + set prepaidAmount(double? prepaidAmount); + String? get specialAgreements; set specialAgreements(String? specialAgreements); @@ -64,6 +70,10 @@ class _$$Delivery extends $Delivery { @override final String id; @override + final String paymentMethodId; + @override + final double prepaidAmount; + @override final String? specialAgreements; @override final DeliveryState state; @@ -84,6 +94,8 @@ class _$$Delivery extends $Delivery { required this.erpBelegartId, required this.erpBelegnummer, required this.id, + required this.paymentMethodId, + required this.prepaidAmount, this.specialAgreements, required this.state, this.stateReason, @@ -108,6 +120,8 @@ class _$$Delivery extends $Delivery { erpBelegartId == other.erpBelegartId && erpBelegnummer == other.erpBelegnummer && id == other.id && + paymentMethodId == other.paymentMethodId && + prepaidAmount == other.prepaidAmount && specialAgreements == other.specialAgreements && state == other.state && stateReason == other.stateReason && @@ -125,6 +139,8 @@ class _$$Delivery extends $Delivery { _$hash = $jc(_$hash, erpBelegartId.hashCode); _$hash = $jc(_$hash, erpBelegnummer.hashCode); _$hash = $jc(_$hash, id.hashCode); + _$hash = $jc(_$hash, paymentMethodId.hashCode); + _$hash = $jc(_$hash, prepaidAmount.hashCode); _$hash = $jc(_$hash, specialAgreements.hashCode); _$hash = $jc(_$hash, state.hashCode); _$hash = $jc(_$hash, stateReason.hashCode); @@ -144,6 +160,8 @@ class _$$Delivery extends $Delivery { ..add('erpBelegartId', erpBelegartId) ..add('erpBelegnummer', erpBelegnummer) ..add('id', id) + ..add('paymentMethodId', paymentMethodId) + ..add('prepaidAmount', prepaidAmount) ..add('specialAgreements', specialAgreements) ..add('state', state) ..add('stateReason', stateReason) @@ -198,6 +216,16 @@ class $DeliveryBuilder String? get id => _$this._id; set id(covariant String? id) => _$this._id = id; + String? _paymentMethodId; + String? get paymentMethodId => _$this._paymentMethodId; + set paymentMethodId(covariant String? paymentMethodId) => + _$this._paymentMethodId = paymentMethodId; + + double? _prepaidAmount; + double? get prepaidAmount => _$this._prepaidAmount; + set prepaidAmount(covariant double? prepaidAmount) => + _$this._prepaidAmount = prepaidAmount; + String? _specialAgreements; String? get specialAgreements => _$this._specialAgreements; set specialAgreements(covariant String? specialAgreements) => @@ -231,6 +259,8 @@ class $DeliveryBuilder _erpBelegartId = $v.erpBelegartId; _erpBelegnummer = $v.erpBelegnummer; _id = $v.id; + _paymentMethodId = $v.paymentMethodId; + _prepaidAmount = $v.prepaidAmount; _specialAgreements = $v.specialAgreements; _state = $v.state; _stateReason = $v.stateReason; @@ -269,6 +299,10 @@ class $DeliveryBuilder erpBelegnummer: BuiltValueNullFieldError.checkNotNull( erpBelegnummer, r'$Delivery', 'erpBelegnummer'), id: BuiltValueNullFieldError.checkNotNull(id, r'$Delivery', 'id'), + paymentMethodId: BuiltValueNullFieldError.checkNotNull( + paymentMethodId, r'$Delivery', 'paymentMethodId'), + prepaidAmount: BuiltValueNullFieldError.checkNotNull( + prepaidAmount, r'$Delivery', 'prepaidAmount'), specialAgreements: specialAgreements, state: BuiltValueNullFieldError.checkNotNull( state, r'$Delivery', 'state'), diff --git a/packages/holzleitner_api/lib/src/model/delivery_credit.dart b/packages/holzleitner_api/lib/src/model/delivery_credit.dart new file mode 100644 index 0000000..81e9e8d --- /dev/null +++ b/packages/holzleitner_api/lib/src/model/delivery_credit.dart @@ -0,0 +1,139 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// + +// ignore_for_file: unused_element +import 'package:built_value/built_value.dart'; +import 'package:built_value/serializer.dart'; + +part 'delivery_credit.g.dart'; + +/// Aktuelle Betrags-Gutschrift einer Lieferung (Geld-Nachlass, unabhängig von Stückzahl). Abgeleitet aus dem jüngsten Ereignis im append-only `delivery_credit_audit`; existiert nur, solange der letzte Stand `set` (und nicht `remove`) ist. `delivery_id` macht den Eintrag — wie eine Notiz — clientseitig per Lieferung join-bar. +/// +/// Properties: +/// * [amountCents] - Gutschrift-Betrag in Cent (> 0, ≤ 15000). +/// * [deliveryId] +/// * [reason] +@BuiltValue() +abstract class DeliveryCredit implements Built { + /// Gutschrift-Betrag in Cent (> 0, ≤ 15000). + @BuiltValueField(wireName: r'amountCents') + int get amountCents; + + @BuiltValueField(wireName: r'deliveryId') + String get deliveryId; + + @BuiltValueField(wireName: r'reason') + String get reason; + + DeliveryCredit._(); + + factory DeliveryCredit([void updates(DeliveryCreditBuilder b)]) = _$DeliveryCredit; + + @BuiltValueHook(initializeBuilder: true) + static void _defaults(DeliveryCreditBuilder b) => b; + + @BuiltValueSerializer(custom: true) + static Serializer get serializer => _$DeliveryCreditSerializer(); +} + +class _$DeliveryCreditSerializer implements PrimitiveSerializer { + @override + final Iterable types = const [DeliveryCredit, _$DeliveryCredit]; + + @override + final String wireName = r'DeliveryCredit'; + + Iterable _serializeProperties( + Serializers serializers, + DeliveryCredit object, { + FullType specifiedType = FullType.unspecified, + }) sync* { + yield r'amountCents'; + yield serializers.serialize( + object.amountCents, + specifiedType: const FullType(int), + ); + yield r'deliveryId'; + yield serializers.serialize( + object.deliveryId, + specifiedType: const FullType(String), + ); + yield r'reason'; + yield serializers.serialize( + object.reason, + specifiedType: const FullType(String), + ); + } + + @override + Object serialize( + Serializers serializers, + DeliveryCredit object, { + FullType specifiedType = FullType.unspecified, + }) { + return _serializeProperties(serializers, object, specifiedType: specifiedType).toList(); + } + + void _deserializeProperties( + Serializers serializers, + Object serialized, { + FullType specifiedType = FullType.unspecified, + required List serializedList, + required DeliveryCreditBuilder result, + required List unhandled, + }) { + for (var i = 0; i < serializedList.length; i += 2) { + final key = serializedList[i] as String; + final value = serializedList[i + 1]; + switch (key) { + case r'amountCents': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType(int), + ) as int; + result.amountCents = valueDes; + break; + case r'deliveryId': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType(String), + ) as String; + result.deliveryId = valueDes; + break; + case r'reason': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType(String), + ) as String; + result.reason = valueDes; + break; + default: + unhandled.add(key); + unhandled.add(value); + break; + } + } + } + + @override + DeliveryCredit deserialize( + Serializers serializers, + Object serialized, { + FullType specifiedType = FullType.unspecified, + }) { + final result = DeliveryCreditBuilder(); + final serializedList = (serialized as Iterable).toList(); + final unhandled = []; + _deserializeProperties( + serializers, + serialized, + specifiedType: specifiedType, + serializedList: serializedList, + unhandled: unhandled, + result: result, + ); + return result.build(); + } +} + diff --git a/packages/holzleitner_api/lib/src/model/delivery_credit.g.dart b/packages/holzleitner_api/lib/src/model/delivery_credit.g.dart new file mode 100644 index 0000000..e166b23 --- /dev/null +++ b/packages/holzleitner_api/lib/src/model/delivery_credit.g.dart @@ -0,0 +1,120 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'delivery_credit.dart'; + +// ************************************************************************** +// BuiltValueGenerator +// ************************************************************************** + +class _$DeliveryCredit extends DeliveryCredit { + @override + final int amountCents; + @override + final String deliveryId; + @override + final String reason; + + factory _$DeliveryCredit([void Function(DeliveryCreditBuilder)? updates]) => + (DeliveryCreditBuilder()..update(updates))._build(); + + _$DeliveryCredit._( + {required this.amountCents, + required this.deliveryId, + required this.reason}) + : super._(); + @override + DeliveryCredit rebuild(void Function(DeliveryCreditBuilder) updates) => + (toBuilder()..update(updates)).build(); + + @override + DeliveryCreditBuilder toBuilder() => DeliveryCreditBuilder()..replace(this); + + @override + bool operator ==(Object other) { + if (identical(other, this)) return true; + return other is DeliveryCredit && + amountCents == other.amountCents && + deliveryId == other.deliveryId && + reason == other.reason; + } + + @override + int get hashCode { + var _$hash = 0; + _$hash = $jc(_$hash, amountCents.hashCode); + _$hash = $jc(_$hash, deliveryId.hashCode); + _$hash = $jc(_$hash, reason.hashCode); + _$hash = $jf(_$hash); + return _$hash; + } + + @override + String toString() { + return (newBuiltValueToStringHelper(r'DeliveryCredit') + ..add('amountCents', amountCents) + ..add('deliveryId', deliveryId) + ..add('reason', reason)) + .toString(); + } +} + +class DeliveryCreditBuilder + implements Builder { + _$DeliveryCredit? _$v; + + int? _amountCents; + int? get amountCents => _$this._amountCents; + set amountCents(int? amountCents) => _$this._amountCents = amountCents; + + String? _deliveryId; + String? get deliveryId => _$this._deliveryId; + set deliveryId(String? deliveryId) => _$this._deliveryId = deliveryId; + + String? _reason; + String? get reason => _$this._reason; + set reason(String? reason) => _$this._reason = reason; + + DeliveryCreditBuilder() { + DeliveryCredit._defaults(this); + } + + DeliveryCreditBuilder get _$this { + final $v = _$v; + if ($v != null) { + _amountCents = $v.amountCents; + _deliveryId = $v.deliveryId; + _reason = $v.reason; + _$v = null; + } + return this; + } + + @override + void replace(DeliveryCredit other) { + _$v = other as _$DeliveryCredit; + } + + @override + void update(void Function(DeliveryCreditBuilder)? updates) { + if (updates != null) updates(this); + } + + @override + DeliveryCredit build() => _build(); + + _$DeliveryCredit _build() { + final _$result = _$v ?? + _$DeliveryCredit._( + amountCents: BuiltValueNullFieldError.checkNotNull( + amountCents, r'DeliveryCredit', 'amountCents'), + deliveryId: BuiltValueNullFieldError.checkNotNull( + deliveryId, r'DeliveryCredit', 'deliveryId'), + reason: BuiltValueNullFieldError.checkNotNull( + reason, r'DeliveryCredit', 'reason'), + ); + replace(_$result); + return _$result; + } +} + +// ignore_for_file: deprecated_member_use_from_same_package,type=lint diff --git a/packages/holzleitner_api/lib/src/model/delivery_credit_event_request.dart b/packages/holzleitner_api/lib/src/model/delivery_credit_event_request.dart new file mode 100644 index 0000000..83c2b0d --- /dev/null +++ b/packages/holzleitner_api/lib/src/model/delivery_credit_event_request.dart @@ -0,0 +1,185 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// + +// ignore_for_file: unused_element +import 'package:holzleitner_api/src/model/credit_action.dart'; +import 'package:built_value/built_value.dart'; +import 'package:built_value/serializer.dart'; + +part 'delivery_credit_event_request.g.dart'; + +/// DeliveryCreditEventRequest +/// +/// Properties: +/// * [action] +/// * [amountCents] - Bei `Set` Pflicht: Betrag in Cent (> 0, ≤ 15000, Vielfaches von 1000). +/// * [authorCarId] - Fahrzeug des Akteurs (Audit-Spur). Muss zum Account gehören. +/// * [clientEventId] - Idempotenz-Schlüssel — pro erzeugtem Ereignis genau einmal vergeben. Ein Retry mit derselben Id wendet nichts erneut an. +/// * [reason] - Bei `Set` Pflicht: Begründung. +@BuiltValue() +abstract class DeliveryCreditEventRequest implements Built { + @BuiltValueField(wireName: r'action') + CreditAction get action; + // enum actionEnum { set, remove, }; + + /// Bei `Set` Pflicht: Betrag in Cent (> 0, ≤ 15000, Vielfaches von 1000). + @BuiltValueField(wireName: r'amountCents') + int? get amountCents; + + /// Fahrzeug des Akteurs (Audit-Spur). Muss zum Account gehören. + @BuiltValueField(wireName: r'authorCarId') + String? get authorCarId; + + /// Idempotenz-Schlüssel — pro erzeugtem Ereignis genau einmal vergeben. Ein Retry mit derselben Id wendet nichts erneut an. + @BuiltValueField(wireName: r'clientEventId') + String get clientEventId; + + /// Bei `Set` Pflicht: Begründung. + @BuiltValueField(wireName: r'reason') + String? get reason; + + DeliveryCreditEventRequest._(); + + factory DeliveryCreditEventRequest([void updates(DeliveryCreditEventRequestBuilder b)]) = _$DeliveryCreditEventRequest; + + @BuiltValueHook(initializeBuilder: true) + static void _defaults(DeliveryCreditEventRequestBuilder b) => b; + + @BuiltValueSerializer(custom: true) + static Serializer get serializer => _$DeliveryCreditEventRequestSerializer(); +} + +class _$DeliveryCreditEventRequestSerializer implements PrimitiveSerializer { + @override + final Iterable types = const [DeliveryCreditEventRequest, _$DeliveryCreditEventRequest]; + + @override + final String wireName = r'DeliveryCreditEventRequest'; + + Iterable _serializeProperties( + Serializers serializers, + DeliveryCreditEventRequest object, { + FullType specifiedType = FullType.unspecified, + }) sync* { + yield r'action'; + yield serializers.serialize( + object.action, + specifiedType: const FullType(CreditAction), + ); + if (object.amountCents != null) { + yield r'amountCents'; + yield serializers.serialize( + object.amountCents, + specifiedType: const FullType.nullable(int), + ); + } + if (object.authorCarId != null) { + yield r'authorCarId'; + yield serializers.serialize( + object.authorCarId, + specifiedType: const FullType.nullable(String), + ); + } + yield r'clientEventId'; + yield serializers.serialize( + object.clientEventId, + specifiedType: const FullType(String), + ); + if (object.reason != null) { + yield r'reason'; + yield serializers.serialize( + object.reason, + specifiedType: const FullType.nullable(String), + ); + } + } + + @override + Object serialize( + Serializers serializers, + DeliveryCreditEventRequest object, { + FullType specifiedType = FullType.unspecified, + }) { + return _serializeProperties(serializers, object, specifiedType: specifiedType).toList(); + } + + void _deserializeProperties( + Serializers serializers, + Object serialized, { + FullType specifiedType = FullType.unspecified, + required List serializedList, + required DeliveryCreditEventRequestBuilder result, + required List unhandled, + }) { + for (var i = 0; i < serializedList.length; i += 2) { + final key = serializedList[i] as String; + final value = serializedList[i + 1]; + switch (key) { + case r'action': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType(CreditAction), + ) as CreditAction; + result.action = valueDes; + break; + case r'amountCents': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType.nullable(int), + ) as int?; + if (valueDes == null) continue; + result.amountCents = valueDes; + break; + case r'authorCarId': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType.nullable(String), + ) as String?; + if (valueDes == null) continue; + result.authorCarId = valueDes; + break; + case r'clientEventId': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType(String), + ) as String; + result.clientEventId = valueDes; + break; + case r'reason': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType.nullable(String), + ) as String?; + if (valueDes == null) continue; + result.reason = valueDes; + break; + default: + unhandled.add(key); + unhandled.add(value); + break; + } + } + } + + @override + DeliveryCreditEventRequest deserialize( + Serializers serializers, + Object serialized, { + FullType specifiedType = FullType.unspecified, + }) { + final result = DeliveryCreditEventRequestBuilder(); + final serializedList = (serialized as Iterable).toList(); + final unhandled = []; + _deserializeProperties( + serializers, + serialized, + specifiedType: specifiedType, + serializedList: serializedList, + unhandled: unhandled, + result: result, + ); + return result.build(); + } +} + diff --git a/packages/holzleitner_api/lib/src/model/delivery_credit_event_request.g.dart b/packages/holzleitner_api/lib/src/model/delivery_credit_event_request.g.dart new file mode 100644 index 0000000..eb1ecd9 --- /dev/null +++ b/packages/holzleitner_api/lib/src/model/delivery_credit_event_request.g.dart @@ -0,0 +1,148 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'delivery_credit_event_request.dart'; + +// ************************************************************************** +// BuiltValueGenerator +// ************************************************************************** + +class _$DeliveryCreditEventRequest extends DeliveryCreditEventRequest { + @override + final CreditAction action; + @override + final int? amountCents; + @override + final String? authorCarId; + @override + final String clientEventId; + @override + final String? reason; + + factory _$DeliveryCreditEventRequest( + [void Function(DeliveryCreditEventRequestBuilder)? updates]) => + (DeliveryCreditEventRequestBuilder()..update(updates))._build(); + + _$DeliveryCreditEventRequest._( + {required this.action, + this.amountCents, + this.authorCarId, + required this.clientEventId, + this.reason}) + : super._(); + @override + DeliveryCreditEventRequest rebuild( + void Function(DeliveryCreditEventRequestBuilder) updates) => + (toBuilder()..update(updates)).build(); + + @override + DeliveryCreditEventRequestBuilder toBuilder() => + DeliveryCreditEventRequestBuilder()..replace(this); + + @override + bool operator ==(Object other) { + if (identical(other, this)) return true; + return other is DeliveryCreditEventRequest && + action == other.action && + amountCents == other.amountCents && + authorCarId == other.authorCarId && + clientEventId == other.clientEventId && + reason == other.reason; + } + + @override + int get hashCode { + var _$hash = 0; + _$hash = $jc(_$hash, action.hashCode); + _$hash = $jc(_$hash, amountCents.hashCode); + _$hash = $jc(_$hash, authorCarId.hashCode); + _$hash = $jc(_$hash, clientEventId.hashCode); + _$hash = $jc(_$hash, reason.hashCode); + _$hash = $jf(_$hash); + return _$hash; + } + + @override + String toString() { + return (newBuiltValueToStringHelper(r'DeliveryCreditEventRequest') + ..add('action', action) + ..add('amountCents', amountCents) + ..add('authorCarId', authorCarId) + ..add('clientEventId', clientEventId) + ..add('reason', reason)) + .toString(); + } +} + +class DeliveryCreditEventRequestBuilder + implements + Builder { + _$DeliveryCreditEventRequest? _$v; + + CreditAction? _action; + CreditAction? get action => _$this._action; + set action(CreditAction? action) => _$this._action = action; + + int? _amountCents; + int? get amountCents => _$this._amountCents; + set amountCents(int? amountCents) => _$this._amountCents = amountCents; + + String? _authorCarId; + String? get authorCarId => _$this._authorCarId; + set authorCarId(String? authorCarId) => _$this._authorCarId = authorCarId; + + String? _clientEventId; + String? get clientEventId => _$this._clientEventId; + set clientEventId(String? clientEventId) => + _$this._clientEventId = clientEventId; + + String? _reason; + String? get reason => _$this._reason; + set reason(String? reason) => _$this._reason = reason; + + DeliveryCreditEventRequestBuilder() { + DeliveryCreditEventRequest._defaults(this); + } + + DeliveryCreditEventRequestBuilder get _$this { + final $v = _$v; + if ($v != null) { + _action = $v.action; + _amountCents = $v.amountCents; + _authorCarId = $v.authorCarId; + _clientEventId = $v.clientEventId; + _reason = $v.reason; + _$v = null; + } + return this; + } + + @override + void replace(DeliveryCreditEventRequest other) { + _$v = other as _$DeliveryCreditEventRequest; + } + + @override + void update(void Function(DeliveryCreditEventRequestBuilder)? updates) { + if (updates != null) updates(this); + } + + @override + DeliveryCreditEventRequest build() => _build(); + + _$DeliveryCreditEventRequest _build() { + final _$result = _$v ?? + _$DeliveryCreditEventRequest._( + action: BuiltValueNullFieldError.checkNotNull( + action, r'DeliveryCreditEventRequest', 'action'), + amountCents: amountCents, + authorCarId: authorCarId, + clientEventId: BuiltValueNullFieldError.checkNotNull( + clientEventId, r'DeliveryCreditEventRequest', 'clientEventId'), + reason: reason, + ); + replace(_$result); + return _$result; + } +} + +// ignore_for_file: deprecated_member_use_from_same_package,type=lint diff --git a/packages/holzleitner_api/lib/src/model/delivery_credit_response.dart b/packages/holzleitner_api/lib/src/model/delivery_credit_response.dart new file mode 100644 index 0000000..97c8f92 --- /dev/null +++ b/packages/holzleitner_api/lib/src/model/delivery_credit_response.dart @@ -0,0 +1,111 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// + +// ignore_for_file: unused_element +import 'package:holzleitner_api/src/model/delivery_credit.dart'; +import 'package:built_value/built_value.dart'; +import 'package:built_value/serializer.dart'; + +part 'delivery_credit_response.g.dart'; + +/// DeliveryCreditResponse +/// +/// Properties: +/// * [credit] - Aktueller Stand nach dem Ereignis — `None`, wenn (zuletzt) entfernt. +@BuiltValue() +abstract class DeliveryCreditResponse implements Built { + /// Aktueller Stand nach dem Ereignis — `None`, wenn (zuletzt) entfernt. + @BuiltValueField(wireName: r'credit') + DeliveryCredit? get credit; + + DeliveryCreditResponse._(); + + factory DeliveryCreditResponse([void updates(DeliveryCreditResponseBuilder b)]) = _$DeliveryCreditResponse; + + @BuiltValueHook(initializeBuilder: true) + static void _defaults(DeliveryCreditResponseBuilder b) => b; + + @BuiltValueSerializer(custom: true) + static Serializer get serializer => _$DeliveryCreditResponseSerializer(); +} + +class _$DeliveryCreditResponseSerializer implements PrimitiveSerializer { + @override + final Iterable types = const [DeliveryCreditResponse, _$DeliveryCreditResponse]; + + @override + final String wireName = r'DeliveryCreditResponse'; + + Iterable _serializeProperties( + Serializers serializers, + DeliveryCreditResponse object, { + FullType specifiedType = FullType.unspecified, + }) sync* { + if (object.credit != null) { + yield r'credit'; + yield serializers.serialize( + object.credit, + specifiedType: const FullType.nullable(DeliveryCredit), + ); + } + } + + @override + Object serialize( + Serializers serializers, + DeliveryCreditResponse object, { + FullType specifiedType = FullType.unspecified, + }) { + return _serializeProperties(serializers, object, specifiedType: specifiedType).toList(); + } + + void _deserializeProperties( + Serializers serializers, + Object serialized, { + FullType specifiedType = FullType.unspecified, + required List serializedList, + required DeliveryCreditResponseBuilder result, + required List unhandled, + }) { + for (var i = 0; i < serializedList.length; i += 2) { + final key = serializedList[i] as String; + final value = serializedList[i + 1]; + switch (key) { + case r'credit': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType.nullable(DeliveryCredit), + ) as DeliveryCredit?; + if (valueDes == null) continue; + result.credit.replace(valueDes); + break; + default: + unhandled.add(key); + unhandled.add(value); + break; + } + } + } + + @override + DeliveryCreditResponse deserialize( + Serializers serializers, + Object serialized, { + FullType specifiedType = FullType.unspecified, + }) { + final result = DeliveryCreditResponseBuilder(); + final serializedList = (serialized as Iterable).toList(); + final unhandled = []; + _deserializeProperties( + serializers, + serialized, + specifiedType: specifiedType, + serializedList: serializedList, + unhandled: unhandled, + result: result, + ); + return result.build(); + } +} + diff --git a/packages/holzleitner_api/lib/src/model/delivery_credit_response.g.dart b/packages/holzleitner_api/lib/src/model/delivery_credit_response.g.dart new file mode 100644 index 0000000..823db4b --- /dev/null +++ b/packages/holzleitner_api/lib/src/model/delivery_credit_response.g.dart @@ -0,0 +1,107 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'delivery_credit_response.dart'; + +// ************************************************************************** +// BuiltValueGenerator +// ************************************************************************** + +class _$DeliveryCreditResponse extends DeliveryCreditResponse { + @override + final DeliveryCredit? credit; + + factory _$DeliveryCreditResponse( + [void Function(DeliveryCreditResponseBuilder)? updates]) => + (DeliveryCreditResponseBuilder()..update(updates))._build(); + + _$DeliveryCreditResponse._({this.credit}) : super._(); + @override + DeliveryCreditResponse rebuild( + void Function(DeliveryCreditResponseBuilder) updates) => + (toBuilder()..update(updates)).build(); + + @override + DeliveryCreditResponseBuilder toBuilder() => + DeliveryCreditResponseBuilder()..replace(this); + + @override + bool operator ==(Object other) { + if (identical(other, this)) return true; + return other is DeliveryCreditResponse && credit == other.credit; + } + + @override + int get hashCode { + var _$hash = 0; + _$hash = $jc(_$hash, credit.hashCode); + _$hash = $jf(_$hash); + return _$hash; + } + + @override + String toString() { + return (newBuiltValueToStringHelper(r'DeliveryCreditResponse') + ..add('credit', credit)) + .toString(); + } +} + +class DeliveryCreditResponseBuilder + implements Builder { + _$DeliveryCreditResponse? _$v; + + DeliveryCreditBuilder? _credit; + DeliveryCreditBuilder get credit => + _$this._credit ??= DeliveryCreditBuilder(); + set credit(DeliveryCreditBuilder? credit) => _$this._credit = credit; + + DeliveryCreditResponseBuilder() { + DeliveryCreditResponse._defaults(this); + } + + DeliveryCreditResponseBuilder get _$this { + final $v = _$v; + if ($v != null) { + _credit = $v.credit?.toBuilder(); + _$v = null; + } + return this; + } + + @override + void replace(DeliveryCreditResponse other) { + _$v = other as _$DeliveryCreditResponse; + } + + @override + void update(void Function(DeliveryCreditResponseBuilder)? updates) { + if (updates != null) updates(this); + } + + @override + DeliveryCreditResponse build() => _build(); + + _$DeliveryCreditResponse _build() { + _$DeliveryCreditResponse _$result; + try { + _$result = _$v ?? + _$DeliveryCreditResponse._( + credit: _credit?.build(), + ); + } catch (_) { + late String _$failedField; + try { + _$failedField = 'credit'; + _credit?.build(); + } catch (e) { + throw BuiltValueNestedFieldError( + r'DeliveryCreditResponse', _$failedField, e.toString()); + } + rethrow; + } + replace(_$result); + return _$result; + } +} + +// ignore_for_file: deprecated_member_use_from_same_package,type=lint diff --git a/packages/holzleitner_api/lib/src/model/delivery_item.dart b/packages/holzleitner_api/lib/src/model/delivery_item.dart index c964c76..49aa463 100644 --- a/packages/holzleitner_api/lib/src/model/delivery_item.dart +++ b/packages/holzleitner_api/lib/src/model/delivery_item.dart @@ -17,8 +17,10 @@ part 'delivery_item.g.dart'; /// * [deliveryId] /// * [id] /// * [komponentenArtikelNr] - Bei Items aus einer Stückliste: Artikelnummer der Komponente. Bei regulären Belegzeilen: `None`. +/// * [parentArtikelNr] - Artikelnummer des Oberartikels, zu dem diese Komponente gehört. `None` bei Oberartikeln/regulären Zeilen — die App rückt Komponenten darüber unter ihrem Oberartikel ein. /// * [requiredQuantity] /// * [scanState] +/// * [unitPrice] - Stückpreis (brutto, EUR) aus dem ERP-Sync. Der Warenwert einer Lieferung = Σ `unit_price` × ausgelieferte Menge. /// * [warehouseId] @BuiltValue() abstract class DeliveryItem implements Built { @@ -39,12 +41,20 @@ abstract class DeliveryItem implements Built @BuiltValueField(wireName: r'komponentenArtikelNr') String? get komponentenArtikelNr; + /// Artikelnummer des Oberartikels, zu dem diese Komponente gehört. `None` bei Oberartikeln/regulären Zeilen — die App rückt Komponenten darüber unter ihrem Oberartikel ein. + @BuiltValueField(wireName: r'parentArtikelNr') + String? get parentArtikelNr; + @BuiltValueField(wireName: r'requiredQuantity') int get requiredQuantity; @BuiltValueField(wireName: r'scanState') ScanState get scanState; + /// Stückpreis (brutto, EUR) aus dem ERP-Sync. Der Warenwert einer Lieferung = Σ `unit_price` × ausgelieferte Menge. + @BuiltValueField(wireName: r'unitPrice') + double get unitPrice; + @BuiltValueField(wireName: r'warehouseId') String get warehouseId; @@ -98,6 +108,13 @@ class _$DeliveryItemSerializer implements PrimitiveSerializer { specifiedType: const FullType.nullable(String), ); } + if (object.parentArtikelNr != null) { + yield r'parentArtikelNr'; + yield serializers.serialize( + object.parentArtikelNr, + specifiedType: const FullType.nullable(String), + ); + } yield r'requiredQuantity'; yield serializers.serialize( object.requiredQuantity, @@ -108,6 +125,11 @@ class _$DeliveryItemSerializer implements PrimitiveSerializer { object.scanState, specifiedType: const FullType(ScanState), ); + yield r'unitPrice'; + yield serializers.serialize( + object.unitPrice, + specifiedType: const FullType(double), + ); yield r'warehouseId'; yield serializers.serialize( object.warehouseId, @@ -172,6 +194,14 @@ class _$DeliveryItemSerializer implements PrimitiveSerializer { if (valueDes == null) continue; result.komponentenArtikelNr = valueDes; break; + case r'parentArtikelNr': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType.nullable(String), + ) as String?; + if (valueDes == null) continue; + result.parentArtikelNr = valueDes; + break; case r'requiredQuantity': final valueDes = serializers.deserialize( value, @@ -186,6 +216,13 @@ class _$DeliveryItemSerializer implements PrimitiveSerializer { ) as ScanState; result.scanState.replace(valueDes); break; + case r'unitPrice': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType(double), + ) as double; + result.unitPrice = valueDes; + break; case r'warehouseId': final valueDes = serializers.deserialize( value, diff --git a/packages/holzleitner_api/lib/src/model/delivery_item.g.dart b/packages/holzleitner_api/lib/src/model/delivery_item.g.dart index b686b52..2197054 100644 --- a/packages/holzleitner_api/lib/src/model/delivery_item.g.dart +++ b/packages/holzleitner_api/lib/src/model/delivery_item.g.dart @@ -18,10 +18,14 @@ class _$DeliveryItem extends DeliveryItem { @override final String? komponentenArtikelNr; @override + final String? parentArtikelNr; + @override final int requiredQuantity; @override final ScanState scanState; @override + final double unitPrice; + @override final String warehouseId; factory _$DeliveryItem([void Function(DeliveryItemBuilder)? updates]) => @@ -33,8 +37,10 @@ class _$DeliveryItem extends DeliveryItem { required this.deliveryId, required this.id, this.komponentenArtikelNr, + this.parentArtikelNr, required this.requiredQuantity, required this.scanState, + required this.unitPrice, required this.warehouseId}) : super._(); @override @@ -53,8 +59,10 @@ class _$DeliveryItem extends DeliveryItem { deliveryId == other.deliveryId && id == other.id && komponentenArtikelNr == other.komponentenArtikelNr && + parentArtikelNr == other.parentArtikelNr && requiredQuantity == other.requiredQuantity && scanState == other.scanState && + unitPrice == other.unitPrice && warehouseId == other.warehouseId; } @@ -66,8 +74,10 @@ class _$DeliveryItem extends DeliveryItem { _$hash = $jc(_$hash, deliveryId.hashCode); _$hash = $jc(_$hash, id.hashCode); _$hash = $jc(_$hash, komponentenArtikelNr.hashCode); + _$hash = $jc(_$hash, parentArtikelNr.hashCode); _$hash = $jc(_$hash, requiredQuantity.hashCode); _$hash = $jc(_$hash, scanState.hashCode); + _$hash = $jc(_$hash, unitPrice.hashCode); _$hash = $jc(_$hash, warehouseId.hashCode); _$hash = $jf(_$hash); return _$hash; @@ -81,8 +91,10 @@ class _$DeliveryItem extends DeliveryItem { ..add('deliveryId', deliveryId) ..add('id', id) ..add('komponentenArtikelNr', komponentenArtikelNr) + ..add('parentArtikelNr', parentArtikelNr) ..add('requiredQuantity', requiredQuantity) ..add('scanState', scanState) + ..add('unitPrice', unitPrice) ..add('warehouseId', warehouseId)) .toString(); } @@ -114,6 +126,11 @@ class DeliveryItemBuilder set komponentenArtikelNr(String? komponentenArtikelNr) => _$this._komponentenArtikelNr = komponentenArtikelNr; + String? _parentArtikelNr; + String? get parentArtikelNr => _$this._parentArtikelNr; + set parentArtikelNr(String? parentArtikelNr) => + _$this._parentArtikelNr = parentArtikelNr; + int? _requiredQuantity; int? get requiredQuantity => _$this._requiredQuantity; set requiredQuantity(int? requiredQuantity) => @@ -123,6 +140,10 @@ class DeliveryItemBuilder ScanStateBuilder get scanState => _$this._scanState ??= ScanStateBuilder(); set scanState(ScanStateBuilder? scanState) => _$this._scanState = scanState; + double? _unitPrice; + double? get unitPrice => _$this._unitPrice; + set unitPrice(double? unitPrice) => _$this._unitPrice = unitPrice; + String? _warehouseId; String? get warehouseId => _$this._warehouseId; set warehouseId(String? warehouseId) => _$this._warehouseId = warehouseId; @@ -139,8 +160,10 @@ class DeliveryItemBuilder _deliveryId = $v.deliveryId; _id = $v.id; _komponentenArtikelNr = $v.komponentenArtikelNr; + _parentArtikelNr = $v.parentArtikelNr; _requiredQuantity = $v.requiredQuantity; _scanState = $v.scanState.toBuilder(); + _unitPrice = $v.unitPrice; _warehouseId = $v.warehouseId; _$v = null; } @@ -174,9 +197,12 @@ class DeliveryItemBuilder id: BuiltValueNullFieldError.checkNotNull( id, r'DeliveryItem', 'id'), komponentenArtikelNr: komponentenArtikelNr, + parentArtikelNr: parentArtikelNr, requiredQuantity: BuiltValueNullFieldError.checkNotNull( requiredQuantity, r'DeliveryItem', 'requiredQuantity'), scanState: scanState.build(), + unitPrice: BuiltValueNullFieldError.checkNotNull( + unitPrice, r'DeliveryItem', 'unitPrice'), warehouseId: BuiltValueNullFieldError.checkNotNull( warehouseId, r'DeliveryItem', 'warehouseId'), ); diff --git a/packages/holzleitner_api/lib/src/model/delivery_note.dart b/packages/holzleitner_api/lib/src/model/delivery_note.dart index 0ac37c6..e1a09a5 100644 --- a/packages/holzleitner_api/lib/src/model/delivery_note.dart +++ b/packages/holzleitner_api/lib/src/model/delivery_note.dart @@ -14,9 +14,12 @@ part 'delivery_note.g.dart'; /// * [authorCarId] - Fahrzeug, falls bekannt — nullable bis das Backend Cars verwaltet. /// * [authorPersonalnummer] - Personalnummer des Akteurs (aus dem JWT). Pflicht. /// * [createdAt] +/// * [creditDeliveryItemId] - Wenn die Notiz als Gutschrift-Grund zu einer Belegzeile angelegt wurde: deren `DeliveryItem`-Id. Erlaubt dem Client, die Notiz beim Zurücknehmen der Gutschrift (Unremove) gezielt wieder zu löschen. `None` bei normalen Text-/Foto-Notizen. /// * [deliveryId] /// * [id] /// * [imageAttachment] - Referenz auf einen Bild-Anhang (z. B. Object-Storage-Key/URL). +/// * [imageAttachmentDeleted] - `true`, wenn die lokale Bilddatei nach erfolgreichem Report-Upload gelöscht wurde (das Bild steckt nun im Lieferbericht in DOCUframe). Read-only; die App zeigt dann statt der Vorschau einen Hinweis. Bei Text-Notizen / vorhandenem Bild: `false`. +/// * [isAmountCreditNote] - `true`, wenn die Notiz den Grund einer **Betrags-Gutschrift** (Geld-Nachlass, Lieferungs-Ebene) dokumentiert. Erlaubt dem Client, sie beim Entfernen der Gutschrift gezielt zu löschen. /// * [text] @BuiltValue() abstract class DeliveryNote implements Built { @@ -31,6 +34,10 @@ abstract class DeliveryNote implements Built @BuiltValueField(wireName: r'createdAt') DateTime get createdAt; + /// Wenn die Notiz als Gutschrift-Grund zu einer Belegzeile angelegt wurde: deren `DeliveryItem`-Id. Erlaubt dem Client, die Notiz beim Zurücknehmen der Gutschrift (Unremove) gezielt wieder zu löschen. `None` bei normalen Text-/Foto-Notizen. + @BuiltValueField(wireName: r'creditDeliveryItemId') + String? get creditDeliveryItemId; + @BuiltValueField(wireName: r'deliveryId') String get deliveryId; @@ -41,6 +48,14 @@ abstract class DeliveryNote implements Built @BuiltValueField(wireName: r'imageAttachment') String? get imageAttachment; + /// `true`, wenn die lokale Bilddatei nach erfolgreichem Report-Upload gelöscht wurde (das Bild steckt nun im Lieferbericht in DOCUframe). Read-only; die App zeigt dann statt der Vorschau einen Hinweis. Bei Text-Notizen / vorhandenem Bild: `false`. + @BuiltValueField(wireName: r'imageAttachmentDeleted') + bool? get imageAttachmentDeleted; + + /// `true`, wenn die Notiz den Grund einer **Betrags-Gutschrift** (Geld-Nachlass, Lieferungs-Ebene) dokumentiert. Erlaubt dem Client, sie beim Entfernen der Gutschrift gezielt zu löschen. + @BuiltValueField(wireName: r'isAmountCreditNote') + bool get isAmountCreditNote; + @BuiltValueField(wireName: r'text') String? get text; @@ -84,6 +99,13 @@ class _$DeliveryNoteSerializer implements PrimitiveSerializer { object.createdAt, specifiedType: const FullType(DateTime), ); + if (object.creditDeliveryItemId != null) { + yield r'creditDeliveryItemId'; + yield serializers.serialize( + object.creditDeliveryItemId, + specifiedType: const FullType.nullable(String), + ); + } yield r'deliveryId'; yield serializers.serialize( object.deliveryId, @@ -101,6 +123,18 @@ class _$DeliveryNoteSerializer implements PrimitiveSerializer { specifiedType: const FullType.nullable(String), ); } + if (object.imageAttachmentDeleted != null) { + yield r'imageAttachmentDeleted'; + yield serializers.serialize( + object.imageAttachmentDeleted, + specifiedType: const FullType(bool), + ); + } + yield r'isAmountCreditNote'; + yield serializers.serialize( + object.isAmountCreditNote, + specifiedType: const FullType(bool), + ); if (object.text != null) { yield r'text'; yield serializers.serialize( @@ -153,6 +187,14 @@ class _$DeliveryNoteSerializer implements PrimitiveSerializer { ) as DateTime; result.createdAt = valueDes; break; + case r'creditDeliveryItemId': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType.nullable(String), + ) as String?; + if (valueDes == null) continue; + result.creditDeliveryItemId = valueDes; + break; case r'deliveryId': final valueDes = serializers.deserialize( value, @@ -175,6 +217,20 @@ class _$DeliveryNoteSerializer implements PrimitiveSerializer { if (valueDes == null) continue; result.imageAttachment = valueDes; break; + case r'imageAttachmentDeleted': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType(bool), + ) as bool; + result.imageAttachmentDeleted = valueDes; + break; + case r'isAmountCreditNote': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType(bool), + ) as bool; + result.isAmountCreditNote = valueDes; + break; case r'text': final valueDes = serializers.deserialize( value, diff --git a/packages/holzleitner_api/lib/src/model/delivery_note.g.dart b/packages/holzleitner_api/lib/src/model/delivery_note.g.dart index 7367cb5..7b3c55b 100644 --- a/packages/holzleitner_api/lib/src/model/delivery_note.g.dart +++ b/packages/holzleitner_api/lib/src/model/delivery_note.g.dart @@ -14,12 +14,18 @@ class _$DeliveryNote extends DeliveryNote { @override final DateTime createdAt; @override + final String? creditDeliveryItemId; + @override final String deliveryId; @override final String id; @override final String? imageAttachment; @override + final bool? imageAttachmentDeleted; + @override + final bool isAmountCreditNote; + @override final String? text; factory _$DeliveryNote([void Function(DeliveryNoteBuilder)? updates]) => @@ -29,9 +35,12 @@ class _$DeliveryNote extends DeliveryNote { {this.authorCarId, required this.authorPersonalnummer, required this.createdAt, + this.creditDeliveryItemId, required this.deliveryId, required this.id, this.imageAttachment, + this.imageAttachmentDeleted, + required this.isAmountCreditNote, this.text}) : super._(); @override @@ -48,9 +57,12 @@ class _$DeliveryNote extends DeliveryNote { authorCarId == other.authorCarId && authorPersonalnummer == other.authorPersonalnummer && createdAt == other.createdAt && + creditDeliveryItemId == other.creditDeliveryItemId && deliveryId == other.deliveryId && id == other.id && imageAttachment == other.imageAttachment && + imageAttachmentDeleted == other.imageAttachmentDeleted && + isAmountCreditNote == other.isAmountCreditNote && text == other.text; } @@ -60,9 +72,12 @@ class _$DeliveryNote extends DeliveryNote { _$hash = $jc(_$hash, authorCarId.hashCode); _$hash = $jc(_$hash, authorPersonalnummer.hashCode); _$hash = $jc(_$hash, createdAt.hashCode); + _$hash = $jc(_$hash, creditDeliveryItemId.hashCode); _$hash = $jc(_$hash, deliveryId.hashCode); _$hash = $jc(_$hash, id.hashCode); _$hash = $jc(_$hash, imageAttachment.hashCode); + _$hash = $jc(_$hash, imageAttachmentDeleted.hashCode); + _$hash = $jc(_$hash, isAmountCreditNote.hashCode); _$hash = $jc(_$hash, text.hashCode); _$hash = $jf(_$hash); return _$hash; @@ -74,9 +89,12 @@ class _$DeliveryNote extends DeliveryNote { ..add('authorCarId', authorCarId) ..add('authorPersonalnummer', authorPersonalnummer) ..add('createdAt', createdAt) + ..add('creditDeliveryItemId', creditDeliveryItemId) ..add('deliveryId', deliveryId) ..add('id', id) ..add('imageAttachment', imageAttachment) + ..add('imageAttachmentDeleted', imageAttachmentDeleted) + ..add('isAmountCreditNote', isAmountCreditNote) ..add('text', text)) .toString(); } @@ -99,6 +117,11 @@ class DeliveryNoteBuilder DateTime? get createdAt => _$this._createdAt; set createdAt(DateTime? createdAt) => _$this._createdAt = createdAt; + String? _creditDeliveryItemId; + String? get creditDeliveryItemId => _$this._creditDeliveryItemId; + set creditDeliveryItemId(String? creditDeliveryItemId) => + _$this._creditDeliveryItemId = creditDeliveryItemId; + String? _deliveryId; String? get deliveryId => _$this._deliveryId; set deliveryId(String? deliveryId) => _$this._deliveryId = deliveryId; @@ -112,6 +135,16 @@ class DeliveryNoteBuilder set imageAttachment(String? imageAttachment) => _$this._imageAttachment = imageAttachment; + bool? _imageAttachmentDeleted; + bool? get imageAttachmentDeleted => _$this._imageAttachmentDeleted; + set imageAttachmentDeleted(bool? imageAttachmentDeleted) => + _$this._imageAttachmentDeleted = imageAttachmentDeleted; + + bool? _isAmountCreditNote; + bool? get isAmountCreditNote => _$this._isAmountCreditNote; + set isAmountCreditNote(bool? isAmountCreditNote) => + _$this._isAmountCreditNote = isAmountCreditNote; + String? _text; String? get text => _$this._text; set text(String? text) => _$this._text = text; @@ -126,9 +159,12 @@ class DeliveryNoteBuilder _authorCarId = $v.authorCarId; _authorPersonalnummer = $v.authorPersonalnummer; _createdAt = $v.createdAt; + _creditDeliveryItemId = $v.creditDeliveryItemId; _deliveryId = $v.deliveryId; _id = $v.id; _imageAttachment = $v.imageAttachment; + _imageAttachmentDeleted = $v.imageAttachmentDeleted; + _isAmountCreditNote = $v.isAmountCreditNote; _text = $v.text; _$v = null; } @@ -156,10 +192,14 @@ class DeliveryNoteBuilder authorPersonalnummer, r'DeliveryNote', 'authorPersonalnummer'), createdAt: BuiltValueNullFieldError.checkNotNull( createdAt, r'DeliveryNote', 'createdAt'), + creditDeliveryItemId: creditDeliveryItemId, deliveryId: BuiltValueNullFieldError.checkNotNull( deliveryId, r'DeliveryNote', 'deliveryId'), id: BuiltValueNullFieldError.checkNotNull(id, r'DeliveryNote', 'id'), imageAttachment: imageAttachment, + imageAttachmentDeleted: imageAttachmentDeleted, + isAmountCreditNote: BuiltValueNullFieldError.checkNotNull( + isAmountCreditNote, r'DeliveryNote', 'isAmountCreditNote'), text: text, ); replace(_$result); diff --git a/packages/holzleitner_api/lib/src/model/delivery_service_response.dart b/packages/holzleitner_api/lib/src/model/delivery_service_response.dart new file mode 100644 index 0000000..96a87b3 --- /dev/null +++ b/packages/holzleitner_api/lib/src/model/delivery_service_response.dart @@ -0,0 +1,107 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// + +// ignore_for_file: unused_element +import 'package:holzleitner_api/src/model/delivery_service_value.dart'; +import 'package:built_value/built_value.dart'; +import 'package:built_value/serializer.dart'; + +part 'delivery_service_response.g.dart'; + +/// DeliveryServiceResponse +/// +/// Properties: +/// * [value] +@BuiltValue() +abstract class DeliveryServiceResponse implements Built { + @BuiltValueField(wireName: r'value') + DeliveryServiceValue get value; + + DeliveryServiceResponse._(); + + factory DeliveryServiceResponse([void updates(DeliveryServiceResponseBuilder b)]) = _$DeliveryServiceResponse; + + @BuiltValueHook(initializeBuilder: true) + static void _defaults(DeliveryServiceResponseBuilder b) => b; + + @BuiltValueSerializer(custom: true) + static Serializer get serializer => _$DeliveryServiceResponseSerializer(); +} + +class _$DeliveryServiceResponseSerializer implements PrimitiveSerializer { + @override + final Iterable types = const [DeliveryServiceResponse, _$DeliveryServiceResponse]; + + @override + final String wireName = r'DeliveryServiceResponse'; + + Iterable _serializeProperties( + Serializers serializers, + DeliveryServiceResponse object, { + FullType specifiedType = FullType.unspecified, + }) sync* { + yield r'value'; + yield serializers.serialize( + object.value, + specifiedType: const FullType(DeliveryServiceValue), + ); + } + + @override + Object serialize( + Serializers serializers, + DeliveryServiceResponse object, { + FullType specifiedType = FullType.unspecified, + }) { + return _serializeProperties(serializers, object, specifiedType: specifiedType).toList(); + } + + void _deserializeProperties( + Serializers serializers, + Object serialized, { + FullType specifiedType = FullType.unspecified, + required List serializedList, + required DeliveryServiceResponseBuilder result, + required List unhandled, + }) { + for (var i = 0; i < serializedList.length; i += 2) { + final key = serializedList[i] as String; + final value = serializedList[i + 1]; + switch (key) { + case r'value': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType(DeliveryServiceValue), + ) as DeliveryServiceValue; + result.value.replace(valueDes); + break; + default: + unhandled.add(key); + unhandled.add(value); + break; + } + } + } + + @override + DeliveryServiceResponse deserialize( + Serializers serializers, + Object serialized, { + FullType specifiedType = FullType.unspecified, + }) { + final result = DeliveryServiceResponseBuilder(); + final serializedList = (serialized as Iterable).toList(); + final unhandled = []; + _deserializeProperties( + serializers, + serialized, + specifiedType: specifiedType, + serializedList: serializedList, + unhandled: unhandled, + result: result, + ); + return result.build(); + } +} + diff --git a/packages/holzleitner_api/lib/src/model/delivery_service_response.g.dart b/packages/holzleitner_api/lib/src/model/delivery_service_response.g.dart new file mode 100644 index 0000000..dd65223 --- /dev/null +++ b/packages/holzleitner_api/lib/src/model/delivery_service_response.g.dart @@ -0,0 +1,108 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'delivery_service_response.dart'; + +// ************************************************************************** +// BuiltValueGenerator +// ************************************************************************** + +class _$DeliveryServiceResponse extends DeliveryServiceResponse { + @override + final DeliveryServiceValue value; + + factory _$DeliveryServiceResponse( + [void Function(DeliveryServiceResponseBuilder)? updates]) => + (DeliveryServiceResponseBuilder()..update(updates))._build(); + + _$DeliveryServiceResponse._({required this.value}) : super._(); + @override + DeliveryServiceResponse rebuild( + void Function(DeliveryServiceResponseBuilder) updates) => + (toBuilder()..update(updates)).build(); + + @override + DeliveryServiceResponseBuilder toBuilder() => + DeliveryServiceResponseBuilder()..replace(this); + + @override + bool operator ==(Object other) { + if (identical(other, this)) return true; + return other is DeliveryServiceResponse && value == other.value; + } + + @override + int get hashCode { + var _$hash = 0; + _$hash = $jc(_$hash, value.hashCode); + _$hash = $jf(_$hash); + return _$hash; + } + + @override + String toString() { + return (newBuiltValueToStringHelper(r'DeliveryServiceResponse') + ..add('value', value)) + .toString(); + } +} + +class DeliveryServiceResponseBuilder + implements + Builder { + _$DeliveryServiceResponse? _$v; + + DeliveryServiceValueBuilder? _value; + DeliveryServiceValueBuilder get value => + _$this._value ??= DeliveryServiceValueBuilder(); + set value(DeliveryServiceValueBuilder? value) => _$this._value = value; + + DeliveryServiceResponseBuilder() { + DeliveryServiceResponse._defaults(this); + } + + DeliveryServiceResponseBuilder get _$this { + final $v = _$v; + if ($v != null) { + _value = $v.value.toBuilder(); + _$v = null; + } + return this; + } + + @override + void replace(DeliveryServiceResponse other) { + _$v = other as _$DeliveryServiceResponse; + } + + @override + void update(void Function(DeliveryServiceResponseBuilder)? updates) { + if (updates != null) updates(this); + } + + @override + DeliveryServiceResponse build() => _build(); + + _$DeliveryServiceResponse _build() { + _$DeliveryServiceResponse _$result; + try { + _$result = _$v ?? + _$DeliveryServiceResponse._( + value: value.build(), + ); + } catch (_) { + late String _$failedField; + try { + _$failedField = 'value'; + value.build(); + } catch (e) { + throw BuiltValueNestedFieldError( + r'DeliveryServiceResponse', _$failedField, e.toString()); + } + rethrow; + } + replace(_$result); + return _$result; + } +} + +// ignore_for_file: deprecated_member_use_from_same_package,type=lint diff --git a/packages/holzleitner_api/lib/src/model/delivery_service_value.dart b/packages/holzleitner_api/lib/src/model/delivery_service_value.dart new file mode 100644 index 0000000..9d60dcd --- /dev/null +++ b/packages/holzleitner_api/lib/src/model/delivery_service_value.dart @@ -0,0 +1,160 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// + +// ignore_for_file: unused_element +import 'package:built_value/built_value.dart'; +import 'package:built_value/serializer.dart'; + +part 'delivery_service_value.g.dart'; + +/// Pro-Lieferung gewählter Wert eines Service. Genau einer der beiden Wert-Slots ist je nach `ServiceKind` gesetzt; per `service_id`/`delivery_id` clientseitig join-bar (wie Notizen/Gutschriften). +/// +/// Properties: +/// * [boolValue] +/// * [deliveryId] +/// * [numericValue] +/// * [serviceId] +@BuiltValue() +abstract class DeliveryServiceValue implements Built { + @BuiltValueField(wireName: r'boolValue') + bool? get boolValue; + + @BuiltValueField(wireName: r'deliveryId') + String get deliveryId; + + @BuiltValueField(wireName: r'numericValue') + int? get numericValue; + + @BuiltValueField(wireName: r'serviceId') + String get serviceId; + + DeliveryServiceValue._(); + + factory DeliveryServiceValue([void updates(DeliveryServiceValueBuilder b)]) = _$DeliveryServiceValue; + + @BuiltValueHook(initializeBuilder: true) + static void _defaults(DeliveryServiceValueBuilder b) => b; + + @BuiltValueSerializer(custom: true) + static Serializer get serializer => _$DeliveryServiceValueSerializer(); +} + +class _$DeliveryServiceValueSerializer implements PrimitiveSerializer { + @override + final Iterable types = const [DeliveryServiceValue, _$DeliveryServiceValue]; + + @override + final String wireName = r'DeliveryServiceValue'; + + Iterable _serializeProperties( + Serializers serializers, + DeliveryServiceValue object, { + FullType specifiedType = FullType.unspecified, + }) sync* { + if (object.boolValue != null) { + yield r'boolValue'; + yield serializers.serialize( + object.boolValue, + specifiedType: const FullType.nullable(bool), + ); + } + yield r'deliveryId'; + yield serializers.serialize( + object.deliveryId, + specifiedType: const FullType(String), + ); + if (object.numericValue != null) { + yield r'numericValue'; + yield serializers.serialize( + object.numericValue, + specifiedType: const FullType.nullable(int), + ); + } + yield r'serviceId'; + yield serializers.serialize( + object.serviceId, + specifiedType: const FullType(String), + ); + } + + @override + Object serialize( + Serializers serializers, + DeliveryServiceValue object, { + FullType specifiedType = FullType.unspecified, + }) { + return _serializeProperties(serializers, object, specifiedType: specifiedType).toList(); + } + + void _deserializeProperties( + Serializers serializers, + Object serialized, { + FullType specifiedType = FullType.unspecified, + required List serializedList, + required DeliveryServiceValueBuilder result, + required List unhandled, + }) { + for (var i = 0; i < serializedList.length; i += 2) { + final key = serializedList[i] as String; + final value = serializedList[i + 1]; + switch (key) { + case r'boolValue': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType.nullable(bool), + ) as bool?; + if (valueDes == null) continue; + result.boolValue = valueDes; + break; + case r'deliveryId': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType(String), + ) as String; + result.deliveryId = valueDes; + break; + case r'numericValue': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType.nullable(int), + ) as int?; + if (valueDes == null) continue; + result.numericValue = valueDes; + break; + case r'serviceId': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType(String), + ) as String; + result.serviceId = valueDes; + break; + default: + unhandled.add(key); + unhandled.add(value); + break; + } + } + } + + @override + DeliveryServiceValue deserialize( + Serializers serializers, + Object serialized, { + FullType specifiedType = FullType.unspecified, + }) { + final result = DeliveryServiceValueBuilder(); + final serializedList = (serialized as Iterable).toList(); + final unhandled = []; + _deserializeProperties( + serializers, + serialized, + specifiedType: specifiedType, + serializedList: serializedList, + unhandled: unhandled, + result: result, + ); + return result.build(); + } +} + diff --git a/packages/holzleitner_api/lib/src/model/delivery_service_value.g.dart b/packages/holzleitner_api/lib/src/model/delivery_service_value.g.dart new file mode 100644 index 0000000..c4542f6 --- /dev/null +++ b/packages/holzleitner_api/lib/src/model/delivery_service_value.g.dart @@ -0,0 +1,134 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'delivery_service_value.dart'; + +// ************************************************************************** +// BuiltValueGenerator +// ************************************************************************** + +class _$DeliveryServiceValue extends DeliveryServiceValue { + @override + final bool? boolValue; + @override + final String deliveryId; + @override + final int? numericValue; + @override + final String serviceId; + + factory _$DeliveryServiceValue( + [void Function(DeliveryServiceValueBuilder)? updates]) => + (DeliveryServiceValueBuilder()..update(updates))._build(); + + _$DeliveryServiceValue._( + {this.boolValue, + required this.deliveryId, + this.numericValue, + required this.serviceId}) + : super._(); + @override + DeliveryServiceValue rebuild( + void Function(DeliveryServiceValueBuilder) updates) => + (toBuilder()..update(updates)).build(); + + @override + DeliveryServiceValueBuilder toBuilder() => + DeliveryServiceValueBuilder()..replace(this); + + @override + bool operator ==(Object other) { + if (identical(other, this)) return true; + return other is DeliveryServiceValue && + boolValue == other.boolValue && + deliveryId == other.deliveryId && + numericValue == other.numericValue && + serviceId == other.serviceId; + } + + @override + int get hashCode { + var _$hash = 0; + _$hash = $jc(_$hash, boolValue.hashCode); + _$hash = $jc(_$hash, deliveryId.hashCode); + _$hash = $jc(_$hash, numericValue.hashCode); + _$hash = $jc(_$hash, serviceId.hashCode); + _$hash = $jf(_$hash); + return _$hash; + } + + @override + String toString() { + return (newBuiltValueToStringHelper(r'DeliveryServiceValue') + ..add('boolValue', boolValue) + ..add('deliveryId', deliveryId) + ..add('numericValue', numericValue) + ..add('serviceId', serviceId)) + .toString(); + } +} + +class DeliveryServiceValueBuilder + implements Builder { + _$DeliveryServiceValue? _$v; + + bool? _boolValue; + bool? get boolValue => _$this._boolValue; + set boolValue(bool? boolValue) => _$this._boolValue = boolValue; + + String? _deliveryId; + String? get deliveryId => _$this._deliveryId; + set deliveryId(String? deliveryId) => _$this._deliveryId = deliveryId; + + int? _numericValue; + int? get numericValue => _$this._numericValue; + set numericValue(int? numericValue) => _$this._numericValue = numericValue; + + String? _serviceId; + String? get serviceId => _$this._serviceId; + set serviceId(String? serviceId) => _$this._serviceId = serviceId; + + DeliveryServiceValueBuilder() { + DeliveryServiceValue._defaults(this); + } + + DeliveryServiceValueBuilder get _$this { + final $v = _$v; + if ($v != null) { + _boolValue = $v.boolValue; + _deliveryId = $v.deliveryId; + _numericValue = $v.numericValue; + _serviceId = $v.serviceId; + _$v = null; + } + return this; + } + + @override + void replace(DeliveryServiceValue other) { + _$v = other as _$DeliveryServiceValue; + } + + @override + void update(void Function(DeliveryServiceValueBuilder)? updates) { + if (updates != null) updates(this); + } + + @override + DeliveryServiceValue build() => _build(); + + _$DeliveryServiceValue _build() { + final _$result = _$v ?? + _$DeliveryServiceValue._( + boolValue: boolValue, + deliveryId: BuiltValueNullFieldError.checkNotNull( + deliveryId, r'DeliveryServiceValue', 'deliveryId'), + numericValue: numericValue, + serviceId: BuiltValueNullFieldError.checkNotNull( + serviceId, r'DeliveryServiceValue', 'serviceId'), + ); + replace(_$result); + return _$result; + } +} + +// ignore_for_file: deprecated_member_use_from_same_package,type=lint diff --git a/packages/holzleitner_api/lib/src/model/delivery_with_items.dart b/packages/holzleitner_api/lib/src/model/delivery_with_items.dart index 5050cc6..7f6debc 100644 --- a/packages/holzleitner_api/lib/src/model/delivery_with_items.dart +++ b/packages/holzleitner_api/lib/src/model/delivery_with_items.dart @@ -24,6 +24,8 @@ part 'delivery_with_items.g.dart'; /// * [erpBelegartId] - ERP-Beleg-Bezug: business-stabiles Paar `(Belegart, Belegnummer)`. Überlebt den Belegkopf-Archivübergang. /// * [erpBelegnummer] /// * [id] +/// * [paymentMethodId] - Für den Restbetrag gewählte Zahlungsart — FK auf `payment_methods`. Vom Kunden bei Bestellung festgelegt, der Fahrer übernimmt nur die Abwicklung. Aktiv-Flag und Anzeige-Name werden über die Stammdaten-Tabelle aufgelöst, nicht hier embeddet. +/// * [prepaidAmount] - Bei Bestellung schon bezahlter Betrag in EUR. `0.0` wenn der Kunde alles bei Lieferung zahlt. Wird vom ERP-Sync gefüllt. /// * [specialAgreements] - Sondervereinbarungen (z. B. „Türklingel defekt, hintenrum klopfen\"). /// * [state] /// * [stateReason] - Begründung bei `state == Held` oder `state == Canceled`. Beim Resume / Complete wieder `None`. @@ -67,6 +69,11 @@ class _$DeliveryWithItemsSerializer implements PrimitiveSerializer _$this._id; set id(covariant String? id) => _$this._id = id; + String? _paymentMethodId; + String? get paymentMethodId => _$this._paymentMethodId; + set paymentMethodId(covariant String? paymentMethodId) => + _$this._paymentMethodId = paymentMethodId; + + double? _prepaidAmount; + double? get prepaidAmount => _$this._prepaidAmount; + set prepaidAmount(covariant double? prepaidAmount) => + _$this._prepaidAmount = prepaidAmount; + String? _specialAgreements; String? get specialAgreements => _$this._specialAgreements; set specialAgreements(covariant String? specialAgreements) => @@ -219,6 +241,8 @@ class DeliveryWithItemsBuilder _erpBelegartId = $v.erpBelegartId; _erpBelegnummer = $v.erpBelegnummer; _id = $v.id; + _paymentMethodId = $v.paymentMethodId; + _prepaidAmount = $v.prepaidAmount; _specialAgreements = $v.specialAgreements; _state = $v.state; _stateReason = $v.stateReason; @@ -261,6 +285,10 @@ class DeliveryWithItemsBuilder erpBelegnummer, r'DeliveryWithItems', 'erpBelegnummer'), id: BuiltValueNullFieldError.checkNotNull( id, r'DeliveryWithItems', 'id'), + paymentMethodId: BuiltValueNullFieldError.checkNotNull( + paymentMethodId, r'DeliveryWithItems', 'paymentMethodId'), + prepaidAmount: BuiltValueNullFieldError.checkNotNull( + prepaidAmount, r'DeliveryWithItems', 'prepaidAmount'), specialAgreements: specialAgreements, state: BuiltValueNullFieldError.checkNotNull( state, r'DeliveryWithItems', 'state'), diff --git a/packages/holzleitner_api/lib/src/model/import_summary.dart b/packages/holzleitner_api/lib/src/model/import_summary.dart new file mode 100644 index 0000000..b72628b --- /dev/null +++ b/packages/holzleitner_api/lib/src/model/import_summary.dart @@ -0,0 +1,211 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// + +// ignore_for_file: unused_element +import 'package:built_collection/built_collection.dart'; +import 'package:holzleitner_api/src/model/date.dart'; +import 'package:built_value/built_value.dart'; +import 'package:built_value/serializer.dart'; + +part 'import_summary.g.dart'; + +/// Ergebnis eines Import-Laufs — pro Fahrer-Tour Erfolg/Fehler getrennt, damit ein einzelner kaputter Beleg nicht den ganzen Tag blockiert. +/// +/// Properties: +/// * [date] +/// * [driversProvisioned] - Anzahl der **neu** im Identity-Provider (Keycloak) angelegten Fahrer-Konten in diesem Lauf (0, wenn Provisionierung deaktiviert ist oder alle Konten bereits existierten). +/// * [errors] - Fehlertexte je fehlgeschlagener Fahrer-Tour (z. B. unbekannter Fahrer → FK auf `accounts`, oder Validierungsfehler). +/// * [provisioningErrors] - Fehlertexte der Konto-Provisionierung (Keycloak). Best-effort: ein Fehler hier blockiert den Touren-Import **nicht**. +/// * [toursFailed] +/// * [toursOk] +/// * [toursTotal] +@BuiltValue() +abstract class ImportSummary implements Built { + @BuiltValueField(wireName: r'date') + Date get date; + + /// Anzahl der **neu** im Identity-Provider (Keycloak) angelegten Fahrer-Konten in diesem Lauf (0, wenn Provisionierung deaktiviert ist oder alle Konten bereits existierten). + @BuiltValueField(wireName: r'driversProvisioned') + int? get driversProvisioned; + + /// Fehlertexte je fehlgeschlagener Fahrer-Tour (z. B. unbekannter Fahrer → FK auf `accounts`, oder Validierungsfehler). + @BuiltValueField(wireName: r'errors') + BuiltList get errors; + + /// Fehlertexte der Konto-Provisionierung (Keycloak). Best-effort: ein Fehler hier blockiert den Touren-Import **nicht**. + @BuiltValueField(wireName: r'provisioningErrors') + BuiltList? get provisioningErrors; + + @BuiltValueField(wireName: r'toursFailed') + int get toursFailed; + + @BuiltValueField(wireName: r'toursOk') + int get toursOk; + + @BuiltValueField(wireName: r'toursTotal') + int get toursTotal; + + ImportSummary._(); + + factory ImportSummary([void updates(ImportSummaryBuilder b)]) = _$ImportSummary; + + @BuiltValueHook(initializeBuilder: true) + static void _defaults(ImportSummaryBuilder b) => b; + + @BuiltValueSerializer(custom: true) + static Serializer get serializer => _$ImportSummarySerializer(); +} + +class _$ImportSummarySerializer implements PrimitiveSerializer { + @override + final Iterable types = const [ImportSummary, _$ImportSummary]; + + @override + final String wireName = r'ImportSummary'; + + Iterable _serializeProperties( + Serializers serializers, + ImportSummary object, { + FullType specifiedType = FullType.unspecified, + }) sync* { + yield r'date'; + yield serializers.serialize( + object.date, + specifiedType: const FullType(Date), + ); + if (object.driversProvisioned != null) { + yield r'driversProvisioned'; + yield serializers.serialize( + object.driversProvisioned, + specifiedType: const FullType(int), + ); + } + yield r'errors'; + yield serializers.serialize( + object.errors, + specifiedType: const FullType(BuiltList, [FullType(String)]), + ); + if (object.provisioningErrors != null) { + yield r'provisioningErrors'; + yield serializers.serialize( + object.provisioningErrors, + specifiedType: const FullType(BuiltList, [FullType(String)]), + ); + } + yield r'toursFailed'; + yield serializers.serialize( + object.toursFailed, + specifiedType: const FullType(int), + ); + yield r'toursOk'; + yield serializers.serialize( + object.toursOk, + specifiedType: const FullType(int), + ); + yield r'toursTotal'; + yield serializers.serialize( + object.toursTotal, + specifiedType: const FullType(int), + ); + } + + @override + Object serialize( + Serializers serializers, + ImportSummary object, { + FullType specifiedType = FullType.unspecified, + }) { + return _serializeProperties(serializers, object, specifiedType: specifiedType).toList(); + } + + void _deserializeProperties( + Serializers serializers, + Object serialized, { + FullType specifiedType = FullType.unspecified, + required List serializedList, + required ImportSummaryBuilder result, + required List unhandled, + }) { + for (var i = 0; i < serializedList.length; i += 2) { + final key = serializedList[i] as String; + final value = serializedList[i + 1]; + switch (key) { + case r'date': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType(Date), + ) as Date; + result.date = valueDes; + break; + case r'driversProvisioned': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType(int), + ) as int; + result.driversProvisioned = valueDes; + break; + case r'errors': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType(BuiltList, [FullType(String)]), + ) as BuiltList; + result.errors.replace(valueDes); + break; + case r'provisioningErrors': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType(BuiltList, [FullType(String)]), + ) as BuiltList; + result.provisioningErrors.replace(valueDes); + break; + case r'toursFailed': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType(int), + ) as int; + result.toursFailed = valueDes; + break; + case r'toursOk': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType(int), + ) as int; + result.toursOk = valueDes; + break; + case r'toursTotal': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType(int), + ) as int; + result.toursTotal = valueDes; + break; + default: + unhandled.add(key); + unhandled.add(value); + break; + } + } + } + + @override + ImportSummary deserialize( + Serializers serializers, + Object serialized, { + FullType specifiedType = FullType.unspecified, + }) { + final result = ImportSummaryBuilder(); + final serializedList = (serialized as Iterable).toList(); + final unhandled = []; + _deserializeProperties( + serializers, + serialized, + specifiedType: specifiedType, + serializedList: serializedList, + unhandled: unhandled, + result: result, + ); + return result.build(); + } +} + diff --git a/packages/holzleitner_api/lib/src/model/import_summary.g.dart b/packages/holzleitner_api/lib/src/model/import_summary.g.dart new file mode 100644 index 0000000..0ee846a --- /dev/null +++ b/packages/holzleitner_api/lib/src/model/import_summary.g.dart @@ -0,0 +1,187 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'import_summary.dart'; + +// ************************************************************************** +// BuiltValueGenerator +// ************************************************************************** + +class _$ImportSummary extends ImportSummary { + @override + final Date date; + @override + final int? driversProvisioned; + @override + final BuiltList errors; + @override + final BuiltList? provisioningErrors; + @override + final int toursFailed; + @override + final int toursOk; + @override + final int toursTotal; + + factory _$ImportSummary([void Function(ImportSummaryBuilder)? updates]) => + (ImportSummaryBuilder()..update(updates))._build(); + + _$ImportSummary._( + {required this.date, + this.driversProvisioned, + required this.errors, + this.provisioningErrors, + required this.toursFailed, + required this.toursOk, + required this.toursTotal}) + : super._(); + @override + ImportSummary rebuild(void Function(ImportSummaryBuilder) updates) => + (toBuilder()..update(updates)).build(); + + @override + ImportSummaryBuilder toBuilder() => ImportSummaryBuilder()..replace(this); + + @override + bool operator ==(Object other) { + if (identical(other, this)) return true; + return other is ImportSummary && + date == other.date && + driversProvisioned == other.driversProvisioned && + errors == other.errors && + provisioningErrors == other.provisioningErrors && + toursFailed == other.toursFailed && + toursOk == other.toursOk && + toursTotal == other.toursTotal; + } + + @override + int get hashCode { + var _$hash = 0; + _$hash = $jc(_$hash, date.hashCode); + _$hash = $jc(_$hash, driversProvisioned.hashCode); + _$hash = $jc(_$hash, errors.hashCode); + _$hash = $jc(_$hash, provisioningErrors.hashCode); + _$hash = $jc(_$hash, toursFailed.hashCode); + _$hash = $jc(_$hash, toursOk.hashCode); + _$hash = $jc(_$hash, toursTotal.hashCode); + _$hash = $jf(_$hash); + return _$hash; + } + + @override + String toString() { + return (newBuiltValueToStringHelper(r'ImportSummary') + ..add('date', date) + ..add('driversProvisioned', driversProvisioned) + ..add('errors', errors) + ..add('provisioningErrors', provisioningErrors) + ..add('toursFailed', toursFailed) + ..add('toursOk', toursOk) + ..add('toursTotal', toursTotal)) + .toString(); + } +} + +class ImportSummaryBuilder + implements Builder { + _$ImportSummary? _$v; + + Date? _date; + Date? get date => _$this._date; + set date(Date? date) => _$this._date = date; + + int? _driversProvisioned; + int? get driversProvisioned => _$this._driversProvisioned; + set driversProvisioned(int? driversProvisioned) => + _$this._driversProvisioned = driversProvisioned; + + ListBuilder? _errors; + ListBuilder get errors => _$this._errors ??= ListBuilder(); + set errors(ListBuilder? errors) => _$this._errors = errors; + + ListBuilder? _provisioningErrors; + ListBuilder get provisioningErrors => + _$this._provisioningErrors ??= ListBuilder(); + set provisioningErrors(ListBuilder? provisioningErrors) => + _$this._provisioningErrors = provisioningErrors; + + int? _toursFailed; + int? get toursFailed => _$this._toursFailed; + set toursFailed(int? toursFailed) => _$this._toursFailed = toursFailed; + + int? _toursOk; + int? get toursOk => _$this._toursOk; + set toursOk(int? toursOk) => _$this._toursOk = toursOk; + + int? _toursTotal; + int? get toursTotal => _$this._toursTotal; + set toursTotal(int? toursTotal) => _$this._toursTotal = toursTotal; + + ImportSummaryBuilder() { + ImportSummary._defaults(this); + } + + ImportSummaryBuilder get _$this { + final $v = _$v; + if ($v != null) { + _date = $v.date; + _driversProvisioned = $v.driversProvisioned; + _errors = $v.errors.toBuilder(); + _provisioningErrors = $v.provisioningErrors?.toBuilder(); + _toursFailed = $v.toursFailed; + _toursOk = $v.toursOk; + _toursTotal = $v.toursTotal; + _$v = null; + } + return this; + } + + @override + void replace(ImportSummary other) { + _$v = other as _$ImportSummary; + } + + @override + void update(void Function(ImportSummaryBuilder)? updates) { + if (updates != null) updates(this); + } + + @override + ImportSummary build() => _build(); + + _$ImportSummary _build() { + _$ImportSummary _$result; + try { + _$result = _$v ?? + _$ImportSummary._( + date: BuiltValueNullFieldError.checkNotNull( + date, r'ImportSummary', 'date'), + driversProvisioned: driversProvisioned, + errors: errors.build(), + provisioningErrors: _provisioningErrors?.build(), + toursFailed: BuiltValueNullFieldError.checkNotNull( + toursFailed, r'ImportSummary', 'toursFailed'), + toursOk: BuiltValueNullFieldError.checkNotNull( + toursOk, r'ImportSummary', 'toursOk'), + toursTotal: BuiltValueNullFieldError.checkNotNull( + toursTotal, r'ImportSummary', 'toursTotal'), + ); + } catch (_) { + late String _$failedField; + try { + _$failedField = 'errors'; + errors.build(); + _$failedField = 'provisioningErrors'; + _provisioningErrors?.build(); + } catch (e) { + throw BuiltValueNestedFieldError( + r'ImportSummary', _$failedField, e.toString()); + } + rethrow; + } + replace(_$result); + return _$result; + } +} + +// ignore_for_file: deprecated_member_use_from_same_package,type=lint diff --git a/packages/holzleitner_api/lib/src/model/mark_mail_sent_request.dart b/packages/holzleitner_api/lib/src/model/mark_mail_sent_request.dart new file mode 100644 index 0000000..8ec9e13 --- /dev/null +++ b/packages/holzleitner_api/lib/src/model/mark_mail_sent_request.dart @@ -0,0 +1,108 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// + +// ignore_for_file: unused_element +import 'package:built_collection/built_collection.dart'; +import 'package:built_value/built_value.dart'; +import 'package:built_value/serializer.dart'; + +part 'mark_mail_sent_request.g.dart'; + +/// MarkMailSentRequest +/// +/// Properties: +/// * [belegnummern] - Belegnummern, deren Liefermail erfolgreich versendet wurde und die als versendet markiert werden sollen. +@BuiltValue() +abstract class MarkMailSentRequest implements Built { + /// Belegnummern, deren Liefermail erfolgreich versendet wurde und die als versendet markiert werden sollen. + @BuiltValueField(wireName: r'belegnummern') + BuiltList get belegnummern; + + MarkMailSentRequest._(); + + factory MarkMailSentRequest([void updates(MarkMailSentRequestBuilder b)]) = _$MarkMailSentRequest; + + @BuiltValueHook(initializeBuilder: true) + static void _defaults(MarkMailSentRequestBuilder b) => b; + + @BuiltValueSerializer(custom: true) + static Serializer get serializer => _$MarkMailSentRequestSerializer(); +} + +class _$MarkMailSentRequestSerializer implements PrimitiveSerializer { + @override + final Iterable types = const [MarkMailSentRequest, _$MarkMailSentRequest]; + + @override + final String wireName = r'MarkMailSentRequest'; + + Iterable _serializeProperties( + Serializers serializers, + MarkMailSentRequest object, { + FullType specifiedType = FullType.unspecified, + }) sync* { + yield r'belegnummern'; + yield serializers.serialize( + object.belegnummern, + specifiedType: const FullType(BuiltList, [FullType(String)]), + ); + } + + @override + Object serialize( + Serializers serializers, + MarkMailSentRequest object, { + FullType specifiedType = FullType.unspecified, + }) { + return _serializeProperties(serializers, object, specifiedType: specifiedType).toList(); + } + + void _deserializeProperties( + Serializers serializers, + Object serialized, { + FullType specifiedType = FullType.unspecified, + required List serializedList, + required MarkMailSentRequestBuilder result, + required List unhandled, + }) { + for (var i = 0; i < serializedList.length; i += 2) { + final key = serializedList[i] as String; + final value = serializedList[i + 1]; + switch (key) { + case r'belegnummern': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType(BuiltList, [FullType(String)]), + ) as BuiltList; + result.belegnummern.replace(valueDes); + break; + default: + unhandled.add(key); + unhandled.add(value); + break; + } + } + } + + @override + MarkMailSentRequest deserialize( + Serializers serializers, + Object serialized, { + FullType specifiedType = FullType.unspecified, + }) { + final result = MarkMailSentRequestBuilder(); + final serializedList = (serialized as Iterable).toList(); + final unhandled = []; + _deserializeProperties( + serializers, + serialized, + specifiedType: specifiedType, + serializedList: serializedList, + unhandled: unhandled, + result: result, + ); + return result.build(); + } +} + diff --git a/packages/holzleitner_api/lib/src/model/mark_mail_sent_request.g.dart b/packages/holzleitner_api/lib/src/model/mark_mail_sent_request.g.dart new file mode 100644 index 0000000..7550df4 --- /dev/null +++ b/packages/holzleitner_api/lib/src/model/mark_mail_sent_request.g.dart @@ -0,0 +1,108 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'mark_mail_sent_request.dart'; + +// ************************************************************************** +// BuiltValueGenerator +// ************************************************************************** + +class _$MarkMailSentRequest extends MarkMailSentRequest { + @override + final BuiltList belegnummern; + + factory _$MarkMailSentRequest( + [void Function(MarkMailSentRequestBuilder)? updates]) => + (MarkMailSentRequestBuilder()..update(updates))._build(); + + _$MarkMailSentRequest._({required this.belegnummern}) : super._(); + @override + MarkMailSentRequest rebuild( + void Function(MarkMailSentRequestBuilder) updates) => + (toBuilder()..update(updates)).build(); + + @override + MarkMailSentRequestBuilder toBuilder() => + MarkMailSentRequestBuilder()..replace(this); + + @override + bool operator ==(Object other) { + if (identical(other, this)) return true; + return other is MarkMailSentRequest && belegnummern == other.belegnummern; + } + + @override + int get hashCode { + var _$hash = 0; + _$hash = $jc(_$hash, belegnummern.hashCode); + _$hash = $jf(_$hash); + return _$hash; + } + + @override + String toString() { + return (newBuiltValueToStringHelper(r'MarkMailSentRequest') + ..add('belegnummern', belegnummern)) + .toString(); + } +} + +class MarkMailSentRequestBuilder + implements Builder { + _$MarkMailSentRequest? _$v; + + ListBuilder? _belegnummern; + ListBuilder get belegnummern => + _$this._belegnummern ??= ListBuilder(); + set belegnummern(ListBuilder? belegnummern) => + _$this._belegnummern = belegnummern; + + MarkMailSentRequestBuilder() { + MarkMailSentRequest._defaults(this); + } + + MarkMailSentRequestBuilder get _$this { + final $v = _$v; + if ($v != null) { + _belegnummern = $v.belegnummern.toBuilder(); + _$v = null; + } + return this; + } + + @override + void replace(MarkMailSentRequest other) { + _$v = other as _$MarkMailSentRequest; + } + + @override + void update(void Function(MarkMailSentRequestBuilder)? updates) { + if (updates != null) updates(this); + } + + @override + MarkMailSentRequest build() => _build(); + + _$MarkMailSentRequest _build() { + _$MarkMailSentRequest _$result; + try { + _$result = _$v ?? + _$MarkMailSentRequest._( + belegnummern: belegnummern.build(), + ); + } catch (_) { + late String _$failedField; + try { + _$failedField = 'belegnummern'; + belegnummern.build(); + } catch (e) { + throw BuiltValueNestedFieldError( + r'MarkMailSentRequest', _$failedField, e.toString()); + } + rethrow; + } + replace(_$result); + return _$result; + } +} + +// ignore_for_file: deprecated_member_use_from_same_package,type=lint diff --git a/packages/holzleitner_api/lib/src/model/mark_mail_sent_response.dart b/packages/holzleitner_api/lib/src/model/mark_mail_sent_response.dart new file mode 100644 index 0000000..aa72465 --- /dev/null +++ b/packages/holzleitner_api/lib/src/model/mark_mail_sent_response.dart @@ -0,0 +1,107 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// + +// ignore_for_file: unused_element +import 'package:built_value/built_value.dart'; +import 'package:built_value/serializer.dart'; + +part 'mark_mail_sent_response.g.dart'; + +/// MarkMailSentResponse +/// +/// Properties: +/// * [marked] - Anzahl frisch markierter (vorher offener) Belege. Bereits markierte zählen nicht mit (idempotent). +@BuiltValue() +abstract class MarkMailSentResponse implements Built { + /// Anzahl frisch markierter (vorher offener) Belege. Bereits markierte zählen nicht mit (idempotent). + @BuiltValueField(wireName: r'marked') + int get marked; + + MarkMailSentResponse._(); + + factory MarkMailSentResponse([void updates(MarkMailSentResponseBuilder b)]) = _$MarkMailSentResponse; + + @BuiltValueHook(initializeBuilder: true) + static void _defaults(MarkMailSentResponseBuilder b) => b; + + @BuiltValueSerializer(custom: true) + static Serializer get serializer => _$MarkMailSentResponseSerializer(); +} + +class _$MarkMailSentResponseSerializer implements PrimitiveSerializer { + @override + final Iterable types = const [MarkMailSentResponse, _$MarkMailSentResponse]; + + @override + final String wireName = r'MarkMailSentResponse'; + + Iterable _serializeProperties( + Serializers serializers, + MarkMailSentResponse object, { + FullType specifiedType = FullType.unspecified, + }) sync* { + yield r'marked'; + yield serializers.serialize( + object.marked, + specifiedType: const FullType(int), + ); + } + + @override + Object serialize( + Serializers serializers, + MarkMailSentResponse object, { + FullType specifiedType = FullType.unspecified, + }) { + return _serializeProperties(serializers, object, specifiedType: specifiedType).toList(); + } + + void _deserializeProperties( + Serializers serializers, + Object serialized, { + FullType specifiedType = FullType.unspecified, + required List serializedList, + required MarkMailSentResponseBuilder result, + required List unhandled, + }) { + for (var i = 0; i < serializedList.length; i += 2) { + final key = serializedList[i] as String; + final value = serializedList[i + 1]; + switch (key) { + case r'marked': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType(int), + ) as int; + result.marked = valueDes; + break; + default: + unhandled.add(key); + unhandled.add(value); + break; + } + } + } + + @override + MarkMailSentResponse deserialize( + Serializers serializers, + Object serialized, { + FullType specifiedType = FullType.unspecified, + }) { + final result = MarkMailSentResponseBuilder(); + final serializedList = (serialized as Iterable).toList(); + final unhandled = []; + _deserializeProperties( + serializers, + serialized, + specifiedType: specifiedType, + serializedList: serializedList, + unhandled: unhandled, + result: result, + ); + return result.build(); + } +} + diff --git a/packages/holzleitner_api/lib/src/model/mark_mail_sent_response.g.dart b/packages/holzleitner_api/lib/src/model/mark_mail_sent_response.g.dart new file mode 100644 index 0000000..ff6adf5 --- /dev/null +++ b/packages/holzleitner_api/lib/src/model/mark_mail_sent_response.g.dart @@ -0,0 +1,94 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'mark_mail_sent_response.dart'; + +// ************************************************************************** +// BuiltValueGenerator +// ************************************************************************** + +class _$MarkMailSentResponse extends MarkMailSentResponse { + @override + final int marked; + + factory _$MarkMailSentResponse( + [void Function(MarkMailSentResponseBuilder)? updates]) => + (MarkMailSentResponseBuilder()..update(updates))._build(); + + _$MarkMailSentResponse._({required this.marked}) : super._(); + @override + MarkMailSentResponse rebuild( + void Function(MarkMailSentResponseBuilder) updates) => + (toBuilder()..update(updates)).build(); + + @override + MarkMailSentResponseBuilder toBuilder() => + MarkMailSentResponseBuilder()..replace(this); + + @override + bool operator ==(Object other) { + if (identical(other, this)) return true; + return other is MarkMailSentResponse && marked == other.marked; + } + + @override + int get hashCode { + var _$hash = 0; + _$hash = $jc(_$hash, marked.hashCode); + _$hash = $jf(_$hash); + return _$hash; + } + + @override + String toString() { + return (newBuiltValueToStringHelper(r'MarkMailSentResponse') + ..add('marked', marked)) + .toString(); + } +} + +class MarkMailSentResponseBuilder + implements Builder { + _$MarkMailSentResponse? _$v; + + int? _marked; + int? get marked => _$this._marked; + set marked(int? marked) => _$this._marked = marked; + + MarkMailSentResponseBuilder() { + MarkMailSentResponse._defaults(this); + } + + MarkMailSentResponseBuilder get _$this { + final $v = _$v; + if ($v != null) { + _marked = $v.marked; + _$v = null; + } + return this; + } + + @override + void replace(MarkMailSentResponse other) { + _$v = other as _$MarkMailSentResponse; + } + + @override + void update(void Function(MarkMailSentResponseBuilder)? updates) { + if (updates != null) updates(this); + } + + @override + MarkMailSentResponse build() => _build(); + + _$MarkMailSentResponse _build() { + final _$result = _$v ?? + _$MarkMailSentResponse._( + marked: BuiltValueNullFieldError.checkNotNull( + marked, r'MarkMailSentResponse', 'marked'), + ); + replace(_$result); + return _$result; + } +} + +// ignore_for_file: deprecated_member_use_from_same_package,type=lint diff --git a/packages/holzleitner_api/lib/src/model/payment_method.dart b/packages/holzleitner_api/lib/src/model/payment_method.dart new file mode 100644 index 0000000..9e04741 --- /dev/null +++ b/packages/holzleitner_api/lib/src/model/payment_method.dart @@ -0,0 +1,172 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// + +// ignore_for_file: unused_element +import 'package:built_value/built_value.dart'; +import 'package:built_value/serializer.dart'; + +part 'payment_method.g.dart'; + +/// Zahlungs-Stammdatensatz. Bewusst eine Tabelle und kein Enum: neue Anbieter (PayPal, Klarna, …) kommen über den `POST /payment-methods`-Endpoint hinzu. Domain-Code kann trotzdem fachliche Sonderfälle über den stabilen `code` (z. B. `\"invoice\"` braucht Bonitätsprüfung) referenzieren — die UUID dient nur als FK in `deliveries`. `active = false` ist Soft-Delete: die Methode bleibt referenzierbar für historische Lieferungen, taucht aber in der UI-Auswahl nicht mehr auf. Echtes Löschen ist nur möglich, wenn keine Lieferung sie referenziert — Datenbank-Constraint regelt das via `ON DELETE RESTRICT`. +/// +/// Properties: +/// * [active] +/// * [code] - Stabiler Programm-Identifier — z. B. `\"cash\"`, `\"ec_card\"`. Eindeutig pro Eintrag. Wird vom Aufrufer beim Anlegen gesetzt. +/// * [createdAt] +/// * [id] +/// * [name] - Display-Name in der UI — frei via PATCH änderbar. +@BuiltValue() +abstract class PaymentMethod implements Built { + @BuiltValueField(wireName: r'active') + bool get active; + + /// Stabiler Programm-Identifier — z. B. `\"cash\"`, `\"ec_card\"`. Eindeutig pro Eintrag. Wird vom Aufrufer beim Anlegen gesetzt. + @BuiltValueField(wireName: r'code') + String get code; + + @BuiltValueField(wireName: r'createdAt') + DateTime get createdAt; + + @BuiltValueField(wireName: r'id') + String get id; + + /// Display-Name in der UI — frei via PATCH änderbar. + @BuiltValueField(wireName: r'name') + String get name; + + PaymentMethod._(); + + factory PaymentMethod([void updates(PaymentMethodBuilder b)]) = _$PaymentMethod; + + @BuiltValueHook(initializeBuilder: true) + static void _defaults(PaymentMethodBuilder b) => b; + + @BuiltValueSerializer(custom: true) + static Serializer get serializer => _$PaymentMethodSerializer(); +} + +class _$PaymentMethodSerializer implements PrimitiveSerializer { + @override + final Iterable types = const [PaymentMethod, _$PaymentMethod]; + + @override + final String wireName = r'PaymentMethod'; + + Iterable _serializeProperties( + Serializers serializers, + PaymentMethod object, { + FullType specifiedType = FullType.unspecified, + }) sync* { + yield r'active'; + yield serializers.serialize( + object.active, + specifiedType: const FullType(bool), + ); + yield r'code'; + yield serializers.serialize( + object.code, + specifiedType: const FullType(String), + ); + yield r'createdAt'; + yield serializers.serialize( + object.createdAt, + specifiedType: const FullType(DateTime), + ); + yield r'id'; + yield serializers.serialize( + object.id, + specifiedType: const FullType(String), + ); + yield r'name'; + yield serializers.serialize( + object.name, + specifiedType: const FullType(String), + ); + } + + @override + Object serialize( + Serializers serializers, + PaymentMethod object, { + FullType specifiedType = FullType.unspecified, + }) { + return _serializeProperties(serializers, object, specifiedType: specifiedType).toList(); + } + + void _deserializeProperties( + Serializers serializers, + Object serialized, { + FullType specifiedType = FullType.unspecified, + required List serializedList, + required PaymentMethodBuilder result, + required List unhandled, + }) { + for (var i = 0; i < serializedList.length; i += 2) { + final key = serializedList[i] as String; + final value = serializedList[i + 1]; + switch (key) { + case r'active': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType(bool), + ) as bool; + result.active = valueDes; + break; + case r'code': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType(String), + ) as String; + result.code = valueDes; + break; + case r'createdAt': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType(DateTime), + ) as DateTime; + result.createdAt = valueDes; + break; + case r'id': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType(String), + ) as String; + result.id = valueDes; + break; + case r'name': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType(String), + ) as String; + result.name = valueDes; + break; + default: + unhandled.add(key); + unhandled.add(value); + break; + } + } + } + + @override + PaymentMethod deserialize( + Serializers serializers, + Object serialized, { + FullType specifiedType = FullType.unspecified, + }) { + final result = PaymentMethodBuilder(); + final serializedList = (serialized as Iterable).toList(); + final unhandled = []; + _deserializeProperties( + serializers, + serialized, + specifiedType: specifiedType, + serializedList: serializedList, + unhandled: unhandled, + result: result, + ); + return result.build(); + } +} + diff --git a/packages/holzleitner_api/lib/src/model/payment_method.g.dart b/packages/holzleitner_api/lib/src/model/payment_method.g.dart new file mode 100644 index 0000000..26bc138 --- /dev/null +++ b/packages/holzleitner_api/lib/src/model/payment_method.g.dart @@ -0,0 +1,145 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'payment_method.dart'; + +// ************************************************************************** +// BuiltValueGenerator +// ************************************************************************** + +class _$PaymentMethod extends PaymentMethod { + @override + final bool active; + @override + final String code; + @override + final DateTime createdAt; + @override + final String id; + @override + final String name; + + factory _$PaymentMethod([void Function(PaymentMethodBuilder)? updates]) => + (PaymentMethodBuilder()..update(updates))._build(); + + _$PaymentMethod._( + {required this.active, + required this.code, + required this.createdAt, + required this.id, + required this.name}) + : super._(); + @override + PaymentMethod rebuild(void Function(PaymentMethodBuilder) updates) => + (toBuilder()..update(updates)).build(); + + @override + PaymentMethodBuilder toBuilder() => PaymentMethodBuilder()..replace(this); + + @override + bool operator ==(Object other) { + if (identical(other, this)) return true; + return other is PaymentMethod && + active == other.active && + code == other.code && + createdAt == other.createdAt && + id == other.id && + name == other.name; + } + + @override + int get hashCode { + var _$hash = 0; + _$hash = $jc(_$hash, active.hashCode); + _$hash = $jc(_$hash, code.hashCode); + _$hash = $jc(_$hash, createdAt.hashCode); + _$hash = $jc(_$hash, id.hashCode); + _$hash = $jc(_$hash, name.hashCode); + _$hash = $jf(_$hash); + return _$hash; + } + + @override + String toString() { + return (newBuiltValueToStringHelper(r'PaymentMethod') + ..add('active', active) + ..add('code', code) + ..add('createdAt', createdAt) + ..add('id', id) + ..add('name', name)) + .toString(); + } +} + +class PaymentMethodBuilder + implements Builder { + _$PaymentMethod? _$v; + + bool? _active; + bool? get active => _$this._active; + set active(bool? active) => _$this._active = active; + + String? _code; + String? get code => _$this._code; + set code(String? code) => _$this._code = code; + + DateTime? _createdAt; + DateTime? get createdAt => _$this._createdAt; + set createdAt(DateTime? createdAt) => _$this._createdAt = createdAt; + + String? _id; + String? get id => _$this._id; + set id(String? id) => _$this._id = id; + + String? _name; + String? get name => _$this._name; + set name(String? name) => _$this._name = name; + + PaymentMethodBuilder() { + PaymentMethod._defaults(this); + } + + PaymentMethodBuilder get _$this { + final $v = _$v; + if ($v != null) { + _active = $v.active; + _code = $v.code; + _createdAt = $v.createdAt; + _id = $v.id; + _name = $v.name; + _$v = null; + } + return this; + } + + @override + void replace(PaymentMethod other) { + _$v = other as _$PaymentMethod; + } + + @override + void update(void Function(PaymentMethodBuilder)? updates) { + if (updates != null) updates(this); + } + + @override + PaymentMethod build() => _build(); + + _$PaymentMethod _build() { + final _$result = _$v ?? + _$PaymentMethod._( + active: BuiltValueNullFieldError.checkNotNull( + active, r'PaymentMethod', 'active'), + code: BuiltValueNullFieldError.checkNotNull( + code, r'PaymentMethod', 'code'), + createdAt: BuiltValueNullFieldError.checkNotNull( + createdAt, r'PaymentMethod', 'createdAt'), + id: BuiltValueNullFieldError.checkNotNull(id, r'PaymentMethod', 'id'), + name: BuiltValueNullFieldError.checkNotNull( + name, r'PaymentMethod', 'name'), + ); + replace(_$result); + return _$result; + } +} + +// ignore_for_file: deprecated_member_use_from_same_package,type=lint diff --git a/packages/holzleitner_api/lib/src/model/payment_method_response.dart b/packages/holzleitner_api/lib/src/model/payment_method_response.dart new file mode 100644 index 0000000..ddc25d5 --- /dev/null +++ b/packages/holzleitner_api/lib/src/model/payment_method_response.dart @@ -0,0 +1,107 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// + +// ignore_for_file: unused_element +import 'package:holzleitner_api/src/model/payment_method.dart'; +import 'package:built_value/built_value.dart'; +import 'package:built_value/serializer.dart'; + +part 'payment_method_response.g.dart'; + +/// PaymentMethodResponse +/// +/// Properties: +/// * [method] +@BuiltValue() +abstract class PaymentMethodResponse implements Built { + @BuiltValueField(wireName: r'method') + PaymentMethod get method; + + PaymentMethodResponse._(); + + factory PaymentMethodResponse([void updates(PaymentMethodResponseBuilder b)]) = _$PaymentMethodResponse; + + @BuiltValueHook(initializeBuilder: true) + static void _defaults(PaymentMethodResponseBuilder b) => b; + + @BuiltValueSerializer(custom: true) + static Serializer get serializer => _$PaymentMethodResponseSerializer(); +} + +class _$PaymentMethodResponseSerializer implements PrimitiveSerializer { + @override + final Iterable types = const [PaymentMethodResponse, _$PaymentMethodResponse]; + + @override + final String wireName = r'PaymentMethodResponse'; + + Iterable _serializeProperties( + Serializers serializers, + PaymentMethodResponse object, { + FullType specifiedType = FullType.unspecified, + }) sync* { + yield r'method'; + yield serializers.serialize( + object.method, + specifiedType: const FullType(PaymentMethod), + ); + } + + @override + Object serialize( + Serializers serializers, + PaymentMethodResponse object, { + FullType specifiedType = FullType.unspecified, + }) { + return _serializeProperties(serializers, object, specifiedType: specifiedType).toList(); + } + + void _deserializeProperties( + Serializers serializers, + Object serialized, { + FullType specifiedType = FullType.unspecified, + required List serializedList, + required PaymentMethodResponseBuilder result, + required List unhandled, + }) { + for (var i = 0; i < serializedList.length; i += 2) { + final key = serializedList[i] as String; + final value = serializedList[i + 1]; + switch (key) { + case r'method': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType(PaymentMethod), + ) as PaymentMethod; + result.method.replace(valueDes); + break; + default: + unhandled.add(key); + unhandled.add(value); + break; + } + } + } + + @override + PaymentMethodResponse deserialize( + Serializers serializers, + Object serialized, { + FullType specifiedType = FullType.unspecified, + }) { + final result = PaymentMethodResponseBuilder(); + final serializedList = (serialized as Iterable).toList(); + final unhandled = []; + _deserializeProperties( + serializers, + serialized, + specifiedType: specifiedType, + serializedList: serializedList, + unhandled: unhandled, + result: result, + ); + return result.build(); + } +} + diff --git a/packages/holzleitner_api/lib/src/model/payment_method_response.g.dart b/packages/holzleitner_api/lib/src/model/payment_method_response.g.dart new file mode 100644 index 0000000..33c1a58 --- /dev/null +++ b/packages/holzleitner_api/lib/src/model/payment_method_response.g.dart @@ -0,0 +1,106 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'payment_method_response.dart'; + +// ************************************************************************** +// BuiltValueGenerator +// ************************************************************************** + +class _$PaymentMethodResponse extends PaymentMethodResponse { + @override + final PaymentMethod method; + + factory _$PaymentMethodResponse( + [void Function(PaymentMethodResponseBuilder)? updates]) => + (PaymentMethodResponseBuilder()..update(updates))._build(); + + _$PaymentMethodResponse._({required this.method}) : super._(); + @override + PaymentMethodResponse rebuild( + void Function(PaymentMethodResponseBuilder) updates) => + (toBuilder()..update(updates)).build(); + + @override + PaymentMethodResponseBuilder toBuilder() => + PaymentMethodResponseBuilder()..replace(this); + + @override + bool operator ==(Object other) { + if (identical(other, this)) return true; + return other is PaymentMethodResponse && method == other.method; + } + + @override + int get hashCode { + var _$hash = 0; + _$hash = $jc(_$hash, method.hashCode); + _$hash = $jf(_$hash); + return _$hash; + } + + @override + String toString() { + return (newBuiltValueToStringHelper(r'PaymentMethodResponse') + ..add('method', method)) + .toString(); + } +} + +class PaymentMethodResponseBuilder + implements Builder { + _$PaymentMethodResponse? _$v; + + PaymentMethodBuilder? _method; + PaymentMethodBuilder get method => _$this._method ??= PaymentMethodBuilder(); + set method(PaymentMethodBuilder? method) => _$this._method = method; + + PaymentMethodResponseBuilder() { + PaymentMethodResponse._defaults(this); + } + + PaymentMethodResponseBuilder get _$this { + final $v = _$v; + if ($v != null) { + _method = $v.method.toBuilder(); + _$v = null; + } + return this; + } + + @override + void replace(PaymentMethodResponse other) { + _$v = other as _$PaymentMethodResponse; + } + + @override + void update(void Function(PaymentMethodResponseBuilder)? updates) { + if (updates != null) updates(this); + } + + @override + PaymentMethodResponse build() => _build(); + + _$PaymentMethodResponse _build() { + _$PaymentMethodResponse _$result; + try { + _$result = _$v ?? + _$PaymentMethodResponse._( + method: method.build(), + ); + } catch (_) { + late String _$failedField; + try { + _$failedField = 'method'; + method.build(); + } catch (e) { + throw BuiltValueNestedFieldError( + r'PaymentMethodResponse', _$failedField, e.toString()); + } + rethrow; + } + replace(_$result); + return _$result; + } +} + +// ignore_for_file: deprecated_member_use_from_same_package,type=lint diff --git a/packages/holzleitner_api/lib/src/model/payment_methods_list.dart b/packages/holzleitner_api/lib/src/model/payment_methods_list.dart new file mode 100644 index 0000000..ada955d --- /dev/null +++ b/packages/holzleitner_api/lib/src/model/payment_methods_list.dart @@ -0,0 +1,108 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// + +// ignore_for_file: unused_element +import 'package:built_collection/built_collection.dart'; +import 'package:holzleitner_api/src/model/payment_method.dart'; +import 'package:built_value/built_value.dart'; +import 'package:built_value/serializer.dart'; + +part 'payment_methods_list.g.dart'; + +/// PaymentMethodsList +/// +/// Properties: +/// * [methods] +@BuiltValue() +abstract class PaymentMethodsList implements Built { + @BuiltValueField(wireName: r'methods') + BuiltList get methods; + + PaymentMethodsList._(); + + factory PaymentMethodsList([void updates(PaymentMethodsListBuilder b)]) = _$PaymentMethodsList; + + @BuiltValueHook(initializeBuilder: true) + static void _defaults(PaymentMethodsListBuilder b) => b; + + @BuiltValueSerializer(custom: true) + static Serializer get serializer => _$PaymentMethodsListSerializer(); +} + +class _$PaymentMethodsListSerializer implements PrimitiveSerializer { + @override + final Iterable types = const [PaymentMethodsList, _$PaymentMethodsList]; + + @override + final String wireName = r'PaymentMethodsList'; + + Iterable _serializeProperties( + Serializers serializers, + PaymentMethodsList object, { + FullType specifiedType = FullType.unspecified, + }) sync* { + yield r'methods'; + yield serializers.serialize( + object.methods, + specifiedType: const FullType(BuiltList, [FullType(PaymentMethod)]), + ); + } + + @override + Object serialize( + Serializers serializers, + PaymentMethodsList object, { + FullType specifiedType = FullType.unspecified, + }) { + return _serializeProperties(serializers, object, specifiedType: specifiedType).toList(); + } + + void _deserializeProperties( + Serializers serializers, + Object serialized, { + FullType specifiedType = FullType.unspecified, + required List serializedList, + required PaymentMethodsListBuilder result, + required List unhandled, + }) { + for (var i = 0; i < serializedList.length; i += 2) { + final key = serializedList[i] as String; + final value = serializedList[i + 1]; + switch (key) { + case r'methods': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType(BuiltList, [FullType(PaymentMethod)]), + ) as BuiltList; + result.methods.replace(valueDes); + break; + default: + unhandled.add(key); + unhandled.add(value); + break; + } + } + } + + @override + PaymentMethodsList deserialize( + Serializers serializers, + Object serialized, { + FullType specifiedType = FullType.unspecified, + }) { + final result = PaymentMethodsListBuilder(); + final serializedList = (serialized as Iterable).toList(); + final unhandled = []; + _deserializeProperties( + serializers, + serialized, + specifiedType: specifiedType, + serializedList: serializedList, + unhandled: unhandled, + result: result, + ); + return result.build(); + } +} + diff --git a/packages/holzleitner_api/lib/src/model/payment_methods_list.g.dart b/packages/holzleitner_api/lib/src/model/payment_methods_list.g.dart new file mode 100644 index 0000000..e8a5bb0 --- /dev/null +++ b/packages/holzleitner_api/lib/src/model/payment_methods_list.g.dart @@ -0,0 +1,107 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'payment_methods_list.dart'; + +// ************************************************************************** +// BuiltValueGenerator +// ************************************************************************** + +class _$PaymentMethodsList extends PaymentMethodsList { + @override + final BuiltList methods; + + factory _$PaymentMethodsList( + [void Function(PaymentMethodsListBuilder)? updates]) => + (PaymentMethodsListBuilder()..update(updates))._build(); + + _$PaymentMethodsList._({required this.methods}) : super._(); + @override + PaymentMethodsList rebuild( + void Function(PaymentMethodsListBuilder) updates) => + (toBuilder()..update(updates)).build(); + + @override + PaymentMethodsListBuilder toBuilder() => + PaymentMethodsListBuilder()..replace(this); + + @override + bool operator ==(Object other) { + if (identical(other, this)) return true; + return other is PaymentMethodsList && methods == other.methods; + } + + @override + int get hashCode { + var _$hash = 0; + _$hash = $jc(_$hash, methods.hashCode); + _$hash = $jf(_$hash); + return _$hash; + } + + @override + String toString() { + return (newBuiltValueToStringHelper(r'PaymentMethodsList') + ..add('methods', methods)) + .toString(); + } +} + +class PaymentMethodsListBuilder + implements Builder { + _$PaymentMethodsList? _$v; + + ListBuilder? _methods; + ListBuilder get methods => + _$this._methods ??= ListBuilder(); + set methods(ListBuilder? methods) => _$this._methods = methods; + + PaymentMethodsListBuilder() { + PaymentMethodsList._defaults(this); + } + + PaymentMethodsListBuilder get _$this { + final $v = _$v; + if ($v != null) { + _methods = $v.methods.toBuilder(); + _$v = null; + } + return this; + } + + @override + void replace(PaymentMethodsList other) { + _$v = other as _$PaymentMethodsList; + } + + @override + void update(void Function(PaymentMethodsListBuilder)? updates) { + if (updates != null) updates(this); + } + + @override + PaymentMethodsList build() => _build(); + + _$PaymentMethodsList _build() { + _$PaymentMethodsList _$result; + try { + _$result = _$v ?? + _$PaymentMethodsList._( + methods: methods.build(), + ); + } catch (_) { + late String _$failedField; + try { + _$failedField = 'methods'; + methods.build(); + } catch (e) { + throw BuiltValueNestedFieldError( + r'PaymentMethodsList', _$failedField, e.toString()); + } + rethrow; + } + replace(_$result); + return _$result; + } +} + +// ignore_for_file: deprecated_member_use_from_same_package,type=lint diff --git a/packages/holzleitner_api/lib/src/model/scan_event.dart b/packages/holzleitner_api/lib/src/model/scan_event.dart index e890f59..7616a7c 100644 --- a/packages/holzleitner_api/lib/src/model/scan_event.dart +++ b/packages/holzleitner_api/lib/src/model/scan_event.dart @@ -17,12 +17,14 @@ part 'scan_event.g.dart'; /// * [clientScanId] /// * [clientScannedAt] /// * [deliveryItemId] +/// * [manual] - `true`, wenn der Fahrer die Position **manuell** als geladen bestätigt hat (Fallback ohne Barcode-Scan). Wird nur im Audit (`scan_audit.manual`) festgehalten; an der Mengen-/Status-Logik ändert es nichts. Default `false` (regulärer Barcode-Scan). +/// * [quantity] - Menge für `Remove` / `Unremove` (Mengen-Gutschrift): wie viele Stück der Belegzeile gutgeschrieben bzw. wieder hergestellt werden. `None` = ganze Restmenge (abwärtskompatibel zum bisherigen „ganze Zeile entfernen\"). Bei `Scan`/`Unscan`/`Hold`/`Unhold` ignoriert. Muss, wenn gesetzt, `> 0` sein. /// * [reason] - Pflicht bei `Hold` und `Remove`. Sonst ignoriert. @BuiltValue() abstract class ScanEvent implements Built { @BuiltValueField(wireName: r'action') AuditAction get action; - // enum actionEnum { scan, unscan, hold, unhold, remove, }; + // enum actionEnum { scan, unscan, hold, unhold, remove, unremove, }; /// Fahrzeug, in dem der Scan gemacht wurde. Muss zum angemeldeten Account gehören. `None` ist erlaubt, schwächt aber den Audit-Trail. @BuiltValueField(wireName: r'actorCarId') @@ -37,6 +39,14 @@ abstract class ScanEvent implements Built { @BuiltValueField(wireName: r'deliveryItemId') String get deliveryItemId; + /// `true`, wenn der Fahrer die Position **manuell** als geladen bestätigt hat (Fallback ohne Barcode-Scan). Wird nur im Audit (`scan_audit.manual`) festgehalten; an der Mengen-/Status-Logik ändert es nichts. Default `false` (regulärer Barcode-Scan). + @BuiltValueField(wireName: r'manual') + bool? get manual; + + /// Menge für `Remove` / `Unremove` (Mengen-Gutschrift): wie viele Stück der Belegzeile gutgeschrieben bzw. wieder hergestellt werden. `None` = ganze Restmenge (abwärtskompatibel zum bisherigen „ganze Zeile entfernen\"). Bei `Scan`/`Unscan`/`Hold`/`Unhold` ignoriert. Muss, wenn gesetzt, `> 0` sein. + @BuiltValueField(wireName: r'quantity') + int? get quantity; + /// Pflicht bei `Hold` und `Remove`. Sonst ignoriert. @BuiltValueField(wireName: r'reason') String? get reason; @@ -91,6 +101,20 @@ class _$ScanEventSerializer implements PrimitiveSerializer { object.deliveryItemId, specifiedType: const FullType(String), ); + if (object.manual != null) { + yield r'manual'; + yield serializers.serialize( + object.manual, + specifiedType: const FullType(bool), + ); + } + if (object.quantity != null) { + yield r'quantity'; + yield serializers.serialize( + object.quantity, + specifiedType: const FullType.nullable(int), + ); + } if (object.reason != null) { yield r'reason'; yield serializers.serialize( @@ -157,6 +181,21 @@ class _$ScanEventSerializer implements PrimitiveSerializer { ) as String; result.deliveryItemId = valueDes; break; + case r'manual': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType(bool), + ) as bool; + result.manual = valueDes; + break; + case r'quantity': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType.nullable(int), + ) as int?; + if (valueDes == null) continue; + result.quantity = valueDes; + break; case r'reason': final valueDes = serializers.deserialize( value, diff --git a/packages/holzleitner_api/lib/src/model/scan_event.g.dart b/packages/holzleitner_api/lib/src/model/scan_event.g.dart index 4e84c36..7fb010a 100644 --- a/packages/holzleitner_api/lib/src/model/scan_event.g.dart +++ b/packages/holzleitner_api/lib/src/model/scan_event.g.dart @@ -18,6 +18,10 @@ class _$ScanEvent extends ScanEvent { @override final String deliveryItemId; @override + final bool? manual; + @override + final int? quantity; + @override final String? reason; factory _$ScanEvent([void Function(ScanEventBuilder)? updates]) => @@ -29,6 +33,8 @@ class _$ScanEvent extends ScanEvent { required this.clientScanId, required this.clientScannedAt, required this.deliveryItemId, + this.manual, + this.quantity, this.reason}) : super._(); @override @@ -47,6 +53,8 @@ class _$ScanEvent extends ScanEvent { clientScanId == other.clientScanId && clientScannedAt == other.clientScannedAt && deliveryItemId == other.deliveryItemId && + manual == other.manual && + quantity == other.quantity && reason == other.reason; } @@ -58,6 +66,8 @@ class _$ScanEvent extends ScanEvent { _$hash = $jc(_$hash, clientScanId.hashCode); _$hash = $jc(_$hash, clientScannedAt.hashCode); _$hash = $jc(_$hash, deliveryItemId.hashCode); + _$hash = $jc(_$hash, manual.hashCode); + _$hash = $jc(_$hash, quantity.hashCode); _$hash = $jc(_$hash, reason.hashCode); _$hash = $jf(_$hash); return _$hash; @@ -71,6 +81,8 @@ class _$ScanEvent extends ScanEvent { ..add('clientScanId', clientScanId) ..add('clientScannedAt', clientScannedAt) ..add('deliveryItemId', deliveryItemId) + ..add('manual', manual) + ..add('quantity', quantity) ..add('reason', reason)) .toString(); } @@ -101,6 +113,14 @@ class ScanEventBuilder implements Builder { set deliveryItemId(String? deliveryItemId) => _$this._deliveryItemId = deliveryItemId; + bool? _manual; + bool? get manual => _$this._manual; + set manual(bool? manual) => _$this._manual = manual; + + int? _quantity; + int? get quantity => _$this._quantity; + set quantity(int? quantity) => _$this._quantity = quantity; + String? _reason; String? get reason => _$this._reason; set reason(String? reason) => _$this._reason = reason; @@ -117,6 +137,8 @@ class ScanEventBuilder implements Builder { _clientScanId = $v.clientScanId; _clientScannedAt = $v.clientScannedAt; _deliveryItemId = $v.deliveryItemId; + _manual = $v.manual; + _quantity = $v.quantity; _reason = $v.reason; _$v = null; } @@ -148,6 +170,8 @@ class ScanEventBuilder implements Builder { clientScannedAt, r'ScanEvent', 'clientScannedAt'), deliveryItemId: BuiltValueNullFieldError.checkNotNull( deliveryItemId, r'ScanEvent', 'deliveryItemId'), + manual: manual, + quantity: quantity, reason: reason, ); replace(_$result); diff --git a/packages/holzleitner_api/lib/src/model/scan_state.dart b/packages/holzleitner_api/lib/src/model/scan_state.dart index 22dc4f8..71cb250 100644 --- a/packages/holzleitner_api/lib/src/model/scan_state.dart +++ b/packages/holzleitner_api/lib/src/model/scan_state.dart @@ -12,12 +12,17 @@ part 'scan_state.g.dart'; /// Eingebetteter Scan-Zustand pro [`DeliveryItem`]. Wird durch `ScanAuditEntry`-Events fortgeschrieben — das Audit-Log ist die Wahrheit über das WIE und WANN, dieses Embedded-VO ist die schnelle Wahrheit über das WIEVIEL. /// /// Properties: +/// * [creditedQuantity] - Als Gutschrift entfernte Menge (0..=required_quantity). Eigene Dimension neben `scanned_quantity`: „wie viele Stück dieser Zeile hat der Kunde nicht angenommen\". `status == Removed` entspricht `credited_quantity == required_quantity` (ganze Zeile gutgeschrieben). /// * [heldReason] - Grund bei `status == Held` oder `status == Removed`. /// * [lastUpdatedAt] /// * [scannedQuantity] /// * [status] @BuiltValue() abstract class ScanState implements Built { + /// Als Gutschrift entfernte Menge (0..=required_quantity). Eigene Dimension neben `scanned_quantity`: „wie viele Stück dieser Zeile hat der Kunde nicht angenommen\". `status == Removed` entspricht `credited_quantity == required_quantity` (ganze Zeile gutgeschrieben). + @BuiltValueField(wireName: r'creditedQuantity') + int get creditedQuantity; + /// Grund bei `status == Held` oder `status == Removed`. @BuiltValueField(wireName: r'heldReason') String? get heldReason; @@ -55,6 +60,11 @@ class _$ScanStateSerializer implements PrimitiveSerializer { ScanState object, { FullType specifiedType = FullType.unspecified, }) sync* { + yield r'creditedQuantity'; + yield serializers.serialize( + object.creditedQuantity, + specifiedType: const FullType(int), + ); if (object.heldReason != null) { yield r'heldReason'; yield serializers.serialize( @@ -100,6 +110,13 @@ class _$ScanStateSerializer implements PrimitiveSerializer { final key = serializedList[i] as String; final value = serializedList[i + 1]; switch (key) { + case r'creditedQuantity': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType(int), + ) as int; + result.creditedQuantity = valueDes; + break; case r'heldReason': final valueDes = serializers.deserialize( value, diff --git a/packages/holzleitner_api/lib/src/model/scan_state.g.dart b/packages/holzleitner_api/lib/src/model/scan_state.g.dart index 5a76b1f..f1a30e5 100644 --- a/packages/holzleitner_api/lib/src/model/scan_state.g.dart +++ b/packages/holzleitner_api/lib/src/model/scan_state.g.dart @@ -7,6 +7,8 @@ part of 'scan_state.dart'; // ************************************************************************** class _$ScanState extends ScanState { + @override + final int creditedQuantity; @override final String? heldReason; @override @@ -20,7 +22,8 @@ class _$ScanState extends ScanState { (ScanStateBuilder()..update(updates))._build(); _$ScanState._( - {this.heldReason, + {required this.creditedQuantity, + this.heldReason, required this.lastUpdatedAt, required this.scannedQuantity, required this.status}) @@ -36,6 +39,7 @@ class _$ScanState extends ScanState { bool operator ==(Object other) { if (identical(other, this)) return true; return other is ScanState && + creditedQuantity == other.creditedQuantity && heldReason == other.heldReason && lastUpdatedAt == other.lastUpdatedAt && scannedQuantity == other.scannedQuantity && @@ -45,6 +49,7 @@ class _$ScanState extends ScanState { @override int get hashCode { var _$hash = 0; + _$hash = $jc(_$hash, creditedQuantity.hashCode); _$hash = $jc(_$hash, heldReason.hashCode); _$hash = $jc(_$hash, lastUpdatedAt.hashCode); _$hash = $jc(_$hash, scannedQuantity.hashCode); @@ -56,6 +61,7 @@ class _$ScanState extends ScanState { @override String toString() { return (newBuiltValueToStringHelper(r'ScanState') + ..add('creditedQuantity', creditedQuantity) ..add('heldReason', heldReason) ..add('lastUpdatedAt', lastUpdatedAt) ..add('scannedQuantity', scannedQuantity) @@ -67,6 +73,11 @@ class _$ScanState extends ScanState { class ScanStateBuilder implements Builder { _$ScanState? _$v; + int? _creditedQuantity; + int? get creditedQuantity => _$this._creditedQuantity; + set creditedQuantity(int? creditedQuantity) => + _$this._creditedQuantity = creditedQuantity; + String? _heldReason; String? get heldReason => _$this._heldReason; set heldReason(String? heldReason) => _$this._heldReason = heldReason; @@ -92,6 +103,7 @@ class ScanStateBuilder implements Builder { ScanStateBuilder get _$this { final $v = _$v; if ($v != null) { + _creditedQuantity = $v.creditedQuantity; _heldReason = $v.heldReason; _lastUpdatedAt = $v.lastUpdatedAt; _scannedQuantity = $v.scannedQuantity; @@ -117,6 +129,8 @@ class ScanStateBuilder implements Builder { _$ScanState _build() { final _$result = _$v ?? _$ScanState._( + creditedQuantity: BuiltValueNullFieldError.checkNotNull( + creditedQuantity, r'ScanState', 'creditedQuantity'), heldReason: heldReason, lastUpdatedAt: BuiltValueNullFieldError.checkNotNull( lastUpdatedAt, r'ScanState', 'lastUpdatedAt'), diff --git a/packages/holzleitner_api/lib/src/model/service.dart b/packages/holzleitner_api/lib/src/model/service.dart new file mode 100644 index 0000000..47771dc --- /dev/null +++ b/packages/holzleitner_api/lib/src/model/service.dart @@ -0,0 +1,226 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// + +// ignore_for_file: unused_element +import 'package:holzleitner_api/src/model/service_kind.dart'; +import 'package:built_value/built_value.dart'; +import 'package:built_value/serializer.dart'; + +part 'service.g.dart'; + +/// Service-Stammdatensatz — admin-konfigurierbar (Muster wie `PaymentMethod`). `key` ist der stabile Programm-Identifier (eindeutig), `name` der Anzeige-Name. `min_value`/`max_value` sind nur für `Numeric` relevant. `active = false` ist Soft-Delete (bleibt für historische Lieferungen referenzierbar, fällt aus dem Default-Listing). +/// +/// Properties: +/// * [active] +/// * [id] +/// * [key] +/// * [kind] +/// * [maxValue] +/// * [minValue] +/// * [name] +/// * [sortOrder] +@BuiltValue() +abstract class Service implements Built { + @BuiltValueField(wireName: r'active') + bool get active; + + @BuiltValueField(wireName: r'id') + String get id; + + @BuiltValueField(wireName: r'key') + String get key; + + @BuiltValueField(wireName: r'kind') + ServiceKind get kind; + // enum kindEnum { boolean, numeric, }; + + @BuiltValueField(wireName: r'maxValue') + int? get maxValue; + + @BuiltValueField(wireName: r'minValue') + int? get minValue; + + @BuiltValueField(wireName: r'name') + String get name; + + @BuiltValueField(wireName: r'sortOrder') + int get sortOrder; + + Service._(); + + factory Service([void updates(ServiceBuilder b)]) = _$Service; + + @BuiltValueHook(initializeBuilder: true) + static void _defaults(ServiceBuilder b) => b; + + @BuiltValueSerializer(custom: true) + static Serializer get serializer => _$ServiceSerializer(); +} + +class _$ServiceSerializer implements PrimitiveSerializer { + @override + final Iterable types = const [Service, _$Service]; + + @override + final String wireName = r'Service'; + + Iterable _serializeProperties( + Serializers serializers, + Service object, { + FullType specifiedType = FullType.unspecified, + }) sync* { + yield r'active'; + yield serializers.serialize( + object.active, + specifiedType: const FullType(bool), + ); + yield r'id'; + yield serializers.serialize( + object.id, + specifiedType: const FullType(String), + ); + yield r'key'; + yield serializers.serialize( + object.key, + specifiedType: const FullType(String), + ); + yield r'kind'; + yield serializers.serialize( + object.kind, + specifiedType: const FullType(ServiceKind), + ); + if (object.maxValue != null) { + yield r'maxValue'; + yield serializers.serialize( + object.maxValue, + specifiedType: const FullType.nullable(int), + ); + } + if (object.minValue != null) { + yield r'minValue'; + yield serializers.serialize( + object.minValue, + specifiedType: const FullType.nullable(int), + ); + } + yield r'name'; + yield serializers.serialize( + object.name, + specifiedType: const FullType(String), + ); + yield r'sortOrder'; + yield serializers.serialize( + object.sortOrder, + specifiedType: const FullType(int), + ); + } + + @override + Object serialize( + Serializers serializers, + Service object, { + FullType specifiedType = FullType.unspecified, + }) { + return _serializeProperties(serializers, object, specifiedType: specifiedType).toList(); + } + + void _deserializeProperties( + Serializers serializers, + Object serialized, { + FullType specifiedType = FullType.unspecified, + required List serializedList, + required ServiceBuilder result, + required List unhandled, + }) { + for (var i = 0; i < serializedList.length; i += 2) { + final key = serializedList[i] as String; + final value = serializedList[i + 1]; + switch (key) { + case r'active': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType(bool), + ) as bool; + result.active = valueDes; + break; + case r'id': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType(String), + ) as String; + result.id = valueDes; + break; + case r'key': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType(String), + ) as String; + result.key = valueDes; + break; + case r'kind': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType(ServiceKind), + ) as ServiceKind; + result.kind = valueDes; + break; + case r'maxValue': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType.nullable(int), + ) as int?; + if (valueDes == null) continue; + result.maxValue = valueDes; + break; + case r'minValue': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType.nullable(int), + ) as int?; + if (valueDes == null) continue; + result.minValue = valueDes; + break; + case r'name': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType(String), + ) as String; + result.name = valueDes; + break; + case r'sortOrder': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType(int), + ) as int; + result.sortOrder = valueDes; + break; + default: + unhandled.add(key); + unhandled.add(value); + break; + } + } + } + + @override + Service deserialize( + Serializers serializers, + Object serialized, { + FullType specifiedType = FullType.unspecified, + }) { + final result = ServiceBuilder(); + final serializedList = (serialized as Iterable).toList(); + final unhandled = []; + _deserializeProperties( + serializers, + serialized, + specifiedType: specifiedType, + serializedList: serializedList, + unhandled: unhandled, + result: result, + ); + return result.build(); + } +} + diff --git a/packages/holzleitner_api/lib/src/model/service.g.dart b/packages/holzleitner_api/lib/src/model/service.g.dart new file mode 100644 index 0000000..f12158b --- /dev/null +++ b/packages/holzleitner_api/lib/src/model/service.g.dart @@ -0,0 +1,178 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'service.dart'; + +// ************************************************************************** +// BuiltValueGenerator +// ************************************************************************** + +class _$Service extends Service { + @override + final bool active; + @override + final String id; + @override + final String key; + @override + final ServiceKind kind; + @override + final int? maxValue; + @override + final int? minValue; + @override + final String name; + @override + final int sortOrder; + + factory _$Service([void Function(ServiceBuilder)? updates]) => + (ServiceBuilder()..update(updates))._build(); + + _$Service._( + {required this.active, + required this.id, + required this.key, + required this.kind, + this.maxValue, + this.minValue, + required this.name, + required this.sortOrder}) + : super._(); + @override + Service rebuild(void Function(ServiceBuilder) updates) => + (toBuilder()..update(updates)).build(); + + @override + ServiceBuilder toBuilder() => ServiceBuilder()..replace(this); + + @override + bool operator ==(Object other) { + if (identical(other, this)) return true; + return other is Service && + active == other.active && + id == other.id && + key == other.key && + kind == other.kind && + maxValue == other.maxValue && + minValue == other.minValue && + name == other.name && + sortOrder == other.sortOrder; + } + + @override + int get hashCode { + var _$hash = 0; + _$hash = $jc(_$hash, active.hashCode); + _$hash = $jc(_$hash, id.hashCode); + _$hash = $jc(_$hash, key.hashCode); + _$hash = $jc(_$hash, kind.hashCode); + _$hash = $jc(_$hash, maxValue.hashCode); + _$hash = $jc(_$hash, minValue.hashCode); + _$hash = $jc(_$hash, name.hashCode); + _$hash = $jc(_$hash, sortOrder.hashCode); + _$hash = $jf(_$hash); + return _$hash; + } + + @override + String toString() { + return (newBuiltValueToStringHelper(r'Service') + ..add('active', active) + ..add('id', id) + ..add('key', key) + ..add('kind', kind) + ..add('maxValue', maxValue) + ..add('minValue', minValue) + ..add('name', name) + ..add('sortOrder', sortOrder)) + .toString(); + } +} + +class ServiceBuilder implements Builder { + _$Service? _$v; + + bool? _active; + bool? get active => _$this._active; + set active(bool? active) => _$this._active = active; + + String? _id; + String? get id => _$this._id; + set id(String? id) => _$this._id = id; + + String? _key; + String? get key => _$this._key; + set key(String? key) => _$this._key = key; + + ServiceKind? _kind; + ServiceKind? get kind => _$this._kind; + set kind(ServiceKind? kind) => _$this._kind = kind; + + int? _maxValue; + int? get maxValue => _$this._maxValue; + set maxValue(int? maxValue) => _$this._maxValue = maxValue; + + int? _minValue; + int? get minValue => _$this._minValue; + set minValue(int? minValue) => _$this._minValue = minValue; + + String? _name; + String? get name => _$this._name; + set name(String? name) => _$this._name = name; + + int? _sortOrder; + int? get sortOrder => _$this._sortOrder; + set sortOrder(int? sortOrder) => _$this._sortOrder = sortOrder; + + ServiceBuilder() { + Service._defaults(this); + } + + ServiceBuilder get _$this { + final $v = _$v; + if ($v != null) { + _active = $v.active; + _id = $v.id; + _key = $v.key; + _kind = $v.kind; + _maxValue = $v.maxValue; + _minValue = $v.minValue; + _name = $v.name; + _sortOrder = $v.sortOrder; + _$v = null; + } + return this; + } + + @override + void replace(Service other) { + _$v = other as _$Service; + } + + @override + void update(void Function(ServiceBuilder)? updates) { + if (updates != null) updates(this); + } + + @override + Service build() => _build(); + + _$Service _build() { + final _$result = _$v ?? + _$Service._( + active: BuiltValueNullFieldError.checkNotNull( + active, r'Service', 'active'), + id: BuiltValueNullFieldError.checkNotNull(id, r'Service', 'id'), + key: BuiltValueNullFieldError.checkNotNull(key, r'Service', 'key'), + kind: BuiltValueNullFieldError.checkNotNull(kind, r'Service', 'kind'), + maxValue: maxValue, + minValue: minValue, + name: BuiltValueNullFieldError.checkNotNull(name, r'Service', 'name'), + sortOrder: BuiltValueNullFieldError.checkNotNull( + sortOrder, r'Service', 'sortOrder'), + ); + replace(_$result); + return _$result; + } +} + +// ignore_for_file: deprecated_member_use_from_same_package,type=lint diff --git a/packages/holzleitner_api/lib/src/model/service_kind.dart b/packages/holzleitner_api/lib/src/model/service_kind.dart new file mode 100644 index 0000000..aec3a54 --- /dev/null +++ b/packages/holzleitner_api/lib/src/model/service_kind.dart @@ -0,0 +1,36 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// + +// ignore_for_file: unused_element +import 'package:built_collection/built_collection.dart'; +import 'package:built_value/built_value.dart'; +import 'package:built_value/serializer.dart'; + +part 'service_kind.g.dart'; + +class ServiceKind extends EnumClass { + + /// Eingabetyp eines Service (früher „Lieferoption\"). `Boolean` rendert als Checkbox, `Numeric` als Zahlenfeld mit optionalen Grenzen. + @BuiltValueEnumConst(wireName: r'boolean') + static const ServiceKind boolean = _$boolean; + /// Eingabetyp eines Service (früher „Lieferoption\"). `Boolean` rendert als Checkbox, `Numeric` als Zahlenfeld mit optionalen Grenzen. + @BuiltValueEnumConst(wireName: r'numeric') + static const ServiceKind numeric = _$numeric; + + static Serializer get serializer => _$serviceKindSerializer; + + const ServiceKind._(String name): super(name); + + static BuiltSet get values => _$values; + static ServiceKind valueOf(String name) => _$valueOf(name); +} + +/// Optionally, enum_class can generate a mixin to go with your enum for use +/// with Angular. It exposes your enum constants as getters. So, if you mix it +/// in to your Dart component class, the values become available to the +/// corresponding Angular template. +/// +/// Trigger mixin generation by writing a line like this one next to your enum. +abstract class ServiceKindMixin = Object with _$ServiceKindMixin; + diff --git a/packages/holzleitner_api/lib/src/model/service_kind.g.dart b/packages/holzleitner_api/lib/src/model/service_kind.g.dart new file mode 100644 index 0000000..625ed40 --- /dev/null +++ b/packages/holzleitner_api/lib/src/model/service_kind.g.dart @@ -0,0 +1,71 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'service_kind.dart'; + +// ************************************************************************** +// BuiltValueGenerator +// ************************************************************************** + +const ServiceKind _$boolean = const ServiceKind._('boolean'); +const ServiceKind _$numeric = const ServiceKind._('numeric'); + +ServiceKind _$valueOf(String name) { + switch (name) { + case 'boolean': + return _$boolean; + case 'numeric': + return _$numeric; + default: + throw ArgumentError(name); + } +} + +final BuiltSet _$values = + BuiltSet(const [ + _$boolean, + _$numeric, +]); + +class _$ServiceKindMeta { + const _$ServiceKindMeta(); + ServiceKind get boolean => _$boolean; + ServiceKind get numeric => _$numeric; + ServiceKind valueOf(String name) => _$valueOf(name); + BuiltSet get values => _$values; +} + +abstract class _$ServiceKindMixin { + // ignore: non_constant_identifier_names + _$ServiceKindMeta get ServiceKind => const _$ServiceKindMeta(); +} + +Serializer _$serviceKindSerializer = _$ServiceKindSerializer(); + +class _$ServiceKindSerializer implements PrimitiveSerializer { + static const Map _toWire = const { + 'boolean': 'boolean', + 'numeric': 'numeric', + }; + static const Map _fromWire = const { + 'boolean': 'boolean', + 'numeric': 'numeric', + }; + + @override + final Iterable types = const [ServiceKind]; + @override + final String wireName = 'ServiceKind'; + + @override + Object serialize(Serializers serializers, ServiceKind object, + {FullType specifiedType = FullType.unspecified}) => + _toWire[object.name] ?? object.name; + + @override + ServiceKind deserialize(Serializers serializers, Object serialized, + {FullType specifiedType = FullType.unspecified}) => + ServiceKind.valueOf( + _fromWire[serialized] ?? (serialized is String ? serialized : '')); +} + +// ignore_for_file: deprecated_member_use_from_same_package,type=lint diff --git a/packages/holzleitner_api/lib/src/model/service_response.dart b/packages/holzleitner_api/lib/src/model/service_response.dart new file mode 100644 index 0000000..35f20ba --- /dev/null +++ b/packages/holzleitner_api/lib/src/model/service_response.dart @@ -0,0 +1,107 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// + +// ignore_for_file: unused_element +import 'package:holzleitner_api/src/model/service.dart'; +import 'package:built_value/built_value.dart'; +import 'package:built_value/serializer.dart'; + +part 'service_response.g.dart'; + +/// ServiceResponse +/// +/// Properties: +/// * [service] +@BuiltValue() +abstract class ServiceResponse implements Built { + @BuiltValueField(wireName: r'service') + Service get service; + + ServiceResponse._(); + + factory ServiceResponse([void updates(ServiceResponseBuilder b)]) = _$ServiceResponse; + + @BuiltValueHook(initializeBuilder: true) + static void _defaults(ServiceResponseBuilder b) => b; + + @BuiltValueSerializer(custom: true) + static Serializer get serializer => _$ServiceResponseSerializer(); +} + +class _$ServiceResponseSerializer implements PrimitiveSerializer { + @override + final Iterable types = const [ServiceResponse, _$ServiceResponse]; + + @override + final String wireName = r'ServiceResponse'; + + Iterable _serializeProperties( + Serializers serializers, + ServiceResponse object, { + FullType specifiedType = FullType.unspecified, + }) sync* { + yield r'service'; + yield serializers.serialize( + object.service, + specifiedType: const FullType(Service), + ); + } + + @override + Object serialize( + Serializers serializers, + ServiceResponse object, { + FullType specifiedType = FullType.unspecified, + }) { + return _serializeProperties(serializers, object, specifiedType: specifiedType).toList(); + } + + void _deserializeProperties( + Serializers serializers, + Object serialized, { + FullType specifiedType = FullType.unspecified, + required List serializedList, + required ServiceResponseBuilder result, + required List unhandled, + }) { + for (var i = 0; i < serializedList.length; i += 2) { + final key = serializedList[i] as String; + final value = serializedList[i + 1]; + switch (key) { + case r'service': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType(Service), + ) as Service; + result.service.replace(valueDes); + break; + default: + unhandled.add(key); + unhandled.add(value); + break; + } + } + } + + @override + ServiceResponse deserialize( + Serializers serializers, + Object serialized, { + FullType specifiedType = FullType.unspecified, + }) { + final result = ServiceResponseBuilder(); + final serializedList = (serialized as Iterable).toList(); + final unhandled = []; + _deserializeProperties( + serializers, + serialized, + specifiedType: specifiedType, + serializedList: serializedList, + unhandled: unhandled, + result: result, + ); + return result.build(); + } +} + diff --git a/packages/holzleitner_api/lib/src/model/service_response.g.dart b/packages/holzleitner_api/lib/src/model/service_response.g.dart new file mode 100644 index 0000000..64cdd10 --- /dev/null +++ b/packages/holzleitner_api/lib/src/model/service_response.g.dart @@ -0,0 +1,103 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'service_response.dart'; + +// ************************************************************************** +// BuiltValueGenerator +// ************************************************************************** + +class _$ServiceResponse extends ServiceResponse { + @override + final Service service; + + factory _$ServiceResponse([void Function(ServiceResponseBuilder)? updates]) => + (ServiceResponseBuilder()..update(updates))._build(); + + _$ServiceResponse._({required this.service}) : super._(); + @override + ServiceResponse rebuild(void Function(ServiceResponseBuilder) updates) => + (toBuilder()..update(updates)).build(); + + @override + ServiceResponseBuilder toBuilder() => ServiceResponseBuilder()..replace(this); + + @override + bool operator ==(Object other) { + if (identical(other, this)) return true; + return other is ServiceResponse && service == other.service; + } + + @override + int get hashCode { + var _$hash = 0; + _$hash = $jc(_$hash, service.hashCode); + _$hash = $jf(_$hash); + return _$hash; + } + + @override + String toString() { + return (newBuiltValueToStringHelper(r'ServiceResponse') + ..add('service', service)) + .toString(); + } +} + +class ServiceResponseBuilder + implements Builder { + _$ServiceResponse? _$v; + + ServiceBuilder? _service; + ServiceBuilder get service => _$this._service ??= ServiceBuilder(); + set service(ServiceBuilder? service) => _$this._service = service; + + ServiceResponseBuilder() { + ServiceResponse._defaults(this); + } + + ServiceResponseBuilder get _$this { + final $v = _$v; + if ($v != null) { + _service = $v.service.toBuilder(); + _$v = null; + } + return this; + } + + @override + void replace(ServiceResponse other) { + _$v = other as _$ServiceResponse; + } + + @override + void update(void Function(ServiceResponseBuilder)? updates) { + if (updates != null) updates(this); + } + + @override + ServiceResponse build() => _build(); + + _$ServiceResponse _build() { + _$ServiceResponse _$result; + try { + _$result = _$v ?? + _$ServiceResponse._( + service: service.build(), + ); + } catch (_) { + late String _$failedField; + try { + _$failedField = 'service'; + service.build(); + } catch (e) { + throw BuiltValueNestedFieldError( + r'ServiceResponse', _$failedField, e.toString()); + } + rethrow; + } + replace(_$result); + return _$result; + } +} + +// ignore_for_file: deprecated_member_use_from_same_package,type=lint diff --git a/packages/holzleitner_api/lib/src/model/services_list.dart b/packages/holzleitner_api/lib/src/model/services_list.dart new file mode 100644 index 0000000..48f4f18 --- /dev/null +++ b/packages/holzleitner_api/lib/src/model/services_list.dart @@ -0,0 +1,108 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// + +// ignore_for_file: unused_element +import 'package:built_collection/built_collection.dart'; +import 'package:holzleitner_api/src/model/service.dart'; +import 'package:built_value/built_value.dart'; +import 'package:built_value/serializer.dart'; + +part 'services_list.g.dart'; + +/// ServicesList +/// +/// Properties: +/// * [services] +@BuiltValue() +abstract class ServicesList implements Built { + @BuiltValueField(wireName: r'services') + BuiltList get services; + + ServicesList._(); + + factory ServicesList([void updates(ServicesListBuilder b)]) = _$ServicesList; + + @BuiltValueHook(initializeBuilder: true) + static void _defaults(ServicesListBuilder b) => b; + + @BuiltValueSerializer(custom: true) + static Serializer get serializer => _$ServicesListSerializer(); +} + +class _$ServicesListSerializer implements PrimitiveSerializer { + @override + final Iterable types = const [ServicesList, _$ServicesList]; + + @override + final String wireName = r'ServicesList'; + + Iterable _serializeProperties( + Serializers serializers, + ServicesList object, { + FullType specifiedType = FullType.unspecified, + }) sync* { + yield r'services'; + yield serializers.serialize( + object.services, + specifiedType: const FullType(BuiltList, [FullType(Service)]), + ); + } + + @override + Object serialize( + Serializers serializers, + ServicesList object, { + FullType specifiedType = FullType.unspecified, + }) { + return _serializeProperties(serializers, object, specifiedType: specifiedType).toList(); + } + + void _deserializeProperties( + Serializers serializers, + Object serialized, { + FullType specifiedType = FullType.unspecified, + required List serializedList, + required ServicesListBuilder result, + required List unhandled, + }) { + for (var i = 0; i < serializedList.length; i += 2) { + final key = serializedList[i] as String; + final value = serializedList[i + 1]; + switch (key) { + case r'services': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType(BuiltList, [FullType(Service)]), + ) as BuiltList; + result.services.replace(valueDes); + break; + default: + unhandled.add(key); + unhandled.add(value); + break; + } + } + } + + @override + ServicesList deserialize( + Serializers serializers, + Object serialized, { + FullType specifiedType = FullType.unspecified, + }) { + final result = ServicesListBuilder(); + final serializedList = (serialized as Iterable).toList(); + final unhandled = []; + _deserializeProperties( + serializers, + serialized, + specifiedType: specifiedType, + serializedList: serializedList, + unhandled: unhandled, + result: result, + ); + return result.build(); + } +} + diff --git a/packages/holzleitner_api/lib/src/model/services_list.g.dart b/packages/holzleitner_api/lib/src/model/services_list.g.dart new file mode 100644 index 0000000..8e23879 --- /dev/null +++ b/packages/holzleitner_api/lib/src/model/services_list.g.dart @@ -0,0 +1,104 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'services_list.dart'; + +// ************************************************************************** +// BuiltValueGenerator +// ************************************************************************** + +class _$ServicesList extends ServicesList { + @override + final BuiltList services; + + factory _$ServicesList([void Function(ServicesListBuilder)? updates]) => + (ServicesListBuilder()..update(updates))._build(); + + _$ServicesList._({required this.services}) : super._(); + @override + ServicesList rebuild(void Function(ServicesListBuilder) updates) => + (toBuilder()..update(updates)).build(); + + @override + ServicesListBuilder toBuilder() => ServicesListBuilder()..replace(this); + + @override + bool operator ==(Object other) { + if (identical(other, this)) return true; + return other is ServicesList && services == other.services; + } + + @override + int get hashCode { + var _$hash = 0; + _$hash = $jc(_$hash, services.hashCode); + _$hash = $jf(_$hash); + return _$hash; + } + + @override + String toString() { + return (newBuiltValueToStringHelper(r'ServicesList') + ..add('services', services)) + .toString(); + } +} + +class ServicesListBuilder + implements Builder { + _$ServicesList? _$v; + + ListBuilder? _services; + ListBuilder get services => + _$this._services ??= ListBuilder(); + set services(ListBuilder? services) => _$this._services = services; + + ServicesListBuilder() { + ServicesList._defaults(this); + } + + ServicesListBuilder get _$this { + final $v = _$v; + if ($v != null) { + _services = $v.services.toBuilder(); + _$v = null; + } + return this; + } + + @override + void replace(ServicesList other) { + _$v = other as _$ServicesList; + } + + @override + void update(void Function(ServicesListBuilder)? updates) { + if (updates != null) updates(this); + } + + @override + ServicesList build() => _build(); + + _$ServicesList _build() { + _$ServicesList _$result; + try { + _$result = _$v ?? + _$ServicesList._( + services: services.build(), + ); + } catch (_) { + late String _$failedField; + try { + _$failedField = 'services'; + services.build(); + } catch (e) { + throw BuiltValueNestedFieldError( + r'ServicesList', _$failedField, e.toString()); + } + rethrow; + } + replace(_$result); + return _$result; + } +} + +// ignore_for_file: deprecated_member_use_from_same_package,type=lint diff --git a/packages/holzleitner_api/lib/src/model/set_delivery_service_request.dart b/packages/holzleitner_api/lib/src/model/set_delivery_service_request.dart new file mode 100644 index 0000000..6085020 --- /dev/null +++ b/packages/holzleitner_api/lib/src/model/set_delivery_service_request.dart @@ -0,0 +1,147 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// + +// ignore_for_file: unused_element +import 'package:built_value/built_value.dart'; +import 'package:built_value/serializer.dart'; + +part 'set_delivery_service_request.g.dart'; + +/// Setzt den Wert eines Service für eine Lieferung (Upsert). Es muss genau das zum `ServiceKind` passende Feld gesetzt sein (Use Case prüft das). +/// +/// Properties: +/// * [authorCarId] +/// * [boolValue] +/// * [numericValue] +@BuiltValue() +abstract class SetDeliveryServiceRequest implements Built { + @BuiltValueField(wireName: r'authorCarId') + String? get authorCarId; + + @BuiltValueField(wireName: r'boolValue') + bool? get boolValue; + + @BuiltValueField(wireName: r'numericValue') + int? get numericValue; + + SetDeliveryServiceRequest._(); + + factory SetDeliveryServiceRequest([void updates(SetDeliveryServiceRequestBuilder b)]) = _$SetDeliveryServiceRequest; + + @BuiltValueHook(initializeBuilder: true) + static void _defaults(SetDeliveryServiceRequestBuilder b) => b; + + @BuiltValueSerializer(custom: true) + static Serializer get serializer => _$SetDeliveryServiceRequestSerializer(); +} + +class _$SetDeliveryServiceRequestSerializer implements PrimitiveSerializer { + @override + final Iterable types = const [SetDeliveryServiceRequest, _$SetDeliveryServiceRequest]; + + @override + final String wireName = r'SetDeliveryServiceRequest'; + + Iterable _serializeProperties( + Serializers serializers, + SetDeliveryServiceRequest object, { + FullType specifiedType = FullType.unspecified, + }) sync* { + if (object.authorCarId != null) { + yield r'authorCarId'; + yield serializers.serialize( + object.authorCarId, + specifiedType: const FullType.nullable(String), + ); + } + if (object.boolValue != null) { + yield r'boolValue'; + yield serializers.serialize( + object.boolValue, + specifiedType: const FullType.nullable(bool), + ); + } + if (object.numericValue != null) { + yield r'numericValue'; + yield serializers.serialize( + object.numericValue, + specifiedType: const FullType.nullable(int), + ); + } + } + + @override + Object serialize( + Serializers serializers, + SetDeliveryServiceRequest object, { + FullType specifiedType = FullType.unspecified, + }) { + return _serializeProperties(serializers, object, specifiedType: specifiedType).toList(); + } + + void _deserializeProperties( + Serializers serializers, + Object serialized, { + FullType specifiedType = FullType.unspecified, + required List serializedList, + required SetDeliveryServiceRequestBuilder result, + required List unhandled, + }) { + for (var i = 0; i < serializedList.length; i += 2) { + final key = serializedList[i] as String; + final value = serializedList[i + 1]; + switch (key) { + case r'authorCarId': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType.nullable(String), + ) as String?; + if (valueDes == null) continue; + result.authorCarId = valueDes; + break; + case r'boolValue': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType.nullable(bool), + ) as bool?; + if (valueDes == null) continue; + result.boolValue = valueDes; + break; + case r'numericValue': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType.nullable(int), + ) as int?; + if (valueDes == null) continue; + result.numericValue = valueDes; + break; + default: + unhandled.add(key); + unhandled.add(value); + break; + } + } + } + + @override + SetDeliveryServiceRequest deserialize( + Serializers serializers, + Object serialized, { + FullType specifiedType = FullType.unspecified, + }) { + final result = SetDeliveryServiceRequestBuilder(); + final serializedList = (serialized as Iterable).toList(); + final unhandled = []; + _deserializeProperties( + serializers, + serialized, + specifiedType: specifiedType, + serializedList: serializedList, + unhandled: unhandled, + result: result, + ); + return result.build(); + } +} + diff --git a/packages/holzleitner_api/lib/src/model/set_delivery_service_request.g.dart b/packages/holzleitner_api/lib/src/model/set_delivery_service_request.g.dart new file mode 100644 index 0000000..b069c1c --- /dev/null +++ b/packages/holzleitner_api/lib/src/model/set_delivery_service_request.g.dart @@ -0,0 +1,119 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'set_delivery_service_request.dart'; + +// ************************************************************************** +// BuiltValueGenerator +// ************************************************************************** + +class _$SetDeliveryServiceRequest extends SetDeliveryServiceRequest { + @override + final String? authorCarId; + @override + final bool? boolValue; + @override + final int? numericValue; + + factory _$SetDeliveryServiceRequest( + [void Function(SetDeliveryServiceRequestBuilder)? updates]) => + (SetDeliveryServiceRequestBuilder()..update(updates))._build(); + + _$SetDeliveryServiceRequest._( + {this.authorCarId, this.boolValue, this.numericValue}) + : super._(); + @override + SetDeliveryServiceRequest rebuild( + void Function(SetDeliveryServiceRequestBuilder) updates) => + (toBuilder()..update(updates)).build(); + + @override + SetDeliveryServiceRequestBuilder toBuilder() => + SetDeliveryServiceRequestBuilder()..replace(this); + + @override + bool operator ==(Object other) { + if (identical(other, this)) return true; + return other is SetDeliveryServiceRequest && + authorCarId == other.authorCarId && + boolValue == other.boolValue && + numericValue == other.numericValue; + } + + @override + int get hashCode { + var _$hash = 0; + _$hash = $jc(_$hash, authorCarId.hashCode); + _$hash = $jc(_$hash, boolValue.hashCode); + _$hash = $jc(_$hash, numericValue.hashCode); + _$hash = $jf(_$hash); + return _$hash; + } + + @override + String toString() { + return (newBuiltValueToStringHelper(r'SetDeliveryServiceRequest') + ..add('authorCarId', authorCarId) + ..add('boolValue', boolValue) + ..add('numericValue', numericValue)) + .toString(); + } +} + +class SetDeliveryServiceRequestBuilder + implements + Builder { + _$SetDeliveryServiceRequest? _$v; + + String? _authorCarId; + String? get authorCarId => _$this._authorCarId; + set authorCarId(String? authorCarId) => _$this._authorCarId = authorCarId; + + bool? _boolValue; + bool? get boolValue => _$this._boolValue; + set boolValue(bool? boolValue) => _$this._boolValue = boolValue; + + int? _numericValue; + int? get numericValue => _$this._numericValue; + set numericValue(int? numericValue) => _$this._numericValue = numericValue; + + SetDeliveryServiceRequestBuilder() { + SetDeliveryServiceRequest._defaults(this); + } + + SetDeliveryServiceRequestBuilder get _$this { + final $v = _$v; + if ($v != null) { + _authorCarId = $v.authorCarId; + _boolValue = $v.boolValue; + _numericValue = $v.numericValue; + _$v = null; + } + return this; + } + + @override + void replace(SetDeliveryServiceRequest other) { + _$v = other as _$SetDeliveryServiceRequest; + } + + @override + void update(void Function(SetDeliveryServiceRequestBuilder)? updates) { + if (updates != null) updates(this); + } + + @override + SetDeliveryServiceRequest build() => _build(); + + _$SetDeliveryServiceRequest _build() { + final _$result = _$v ?? + _$SetDeliveryServiceRequest._( + authorCarId: authorCarId, + boolValue: boolValue, + numericValue: numericValue, + ); + replace(_$result); + return _$result; + } +} + +// ignore_for_file: deprecated_member_use_from_same_package,type=lint diff --git a/packages/holzleitner_api/lib/src/model/sync_contact_channel.dart b/packages/holzleitner_api/lib/src/model/sync_contact_channel.dart new file mode 100644 index 0000000..5b6ea19 --- /dev/null +++ b/packages/holzleitner_api/lib/src/model/sync_contact_channel.dart @@ -0,0 +1,141 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// + +// ignore_for_file: unused_element +import 'package:holzleitner_api/src/model/contact_kind.dart'; +import 'package:built_value/built_value.dart'; +import 'package:built_value/serializer.dart'; + +part 'sync_contact_channel.g.dart'; + +/// SyncContactChannel +/// +/// Properties: +/// * [kind] +/// * [position] - 1-basiert: spiegelt ERP-Spaltenposition (Telefon → 1, Telefon2 → 2, …). +/// * [value] +@BuiltValue() +abstract class SyncContactChannel implements Built { + @BuiltValueField(wireName: r'kind') + ContactKind get kind; + // enum kindEnum { phone, mobile, email, web, }; + + /// 1-basiert: spiegelt ERP-Spaltenposition (Telefon → 1, Telefon2 → 2, …). + @BuiltValueField(wireName: r'position') + int get position; + + @BuiltValueField(wireName: r'value') + String get value; + + SyncContactChannel._(); + + factory SyncContactChannel([void updates(SyncContactChannelBuilder b)]) = _$SyncContactChannel; + + @BuiltValueHook(initializeBuilder: true) + static void _defaults(SyncContactChannelBuilder b) => b; + + @BuiltValueSerializer(custom: true) + static Serializer get serializer => _$SyncContactChannelSerializer(); +} + +class _$SyncContactChannelSerializer implements PrimitiveSerializer { + @override + final Iterable types = const [SyncContactChannel, _$SyncContactChannel]; + + @override + final String wireName = r'SyncContactChannel'; + + Iterable _serializeProperties( + Serializers serializers, + SyncContactChannel object, { + FullType specifiedType = FullType.unspecified, + }) sync* { + yield r'kind'; + yield serializers.serialize( + object.kind, + specifiedType: const FullType(ContactKind), + ); + yield r'position'; + yield serializers.serialize( + object.position, + specifiedType: const FullType(int), + ); + yield r'value'; + yield serializers.serialize( + object.value, + specifiedType: const FullType(String), + ); + } + + @override + Object serialize( + Serializers serializers, + SyncContactChannel object, { + FullType specifiedType = FullType.unspecified, + }) { + return _serializeProperties(serializers, object, specifiedType: specifiedType).toList(); + } + + void _deserializeProperties( + Serializers serializers, + Object serialized, { + FullType specifiedType = FullType.unspecified, + required List serializedList, + required SyncContactChannelBuilder result, + required List unhandled, + }) { + for (var i = 0; i < serializedList.length; i += 2) { + final key = serializedList[i] as String; + final value = serializedList[i + 1]; + switch (key) { + case r'kind': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType(ContactKind), + ) as ContactKind; + result.kind = valueDes; + break; + case r'position': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType(int), + ) as int; + result.position = valueDes; + break; + case r'value': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType(String), + ) as String; + result.value = valueDes; + break; + default: + unhandled.add(key); + unhandled.add(value); + break; + } + } + } + + @override + SyncContactChannel deserialize( + Serializers serializers, + Object serialized, { + FullType specifiedType = FullType.unspecified, + }) { + final result = SyncContactChannelBuilder(); + final serializedList = (serialized as Iterable).toList(); + final unhandled = []; + _deserializeProperties( + serializers, + serialized, + specifiedType: specifiedType, + serializedList: serializedList, + unhandled: unhandled, + result: result, + ); + return result.build(); + } +} + diff --git a/packages/holzleitner_api/lib/src/model/sync_contact_channel.g.dart b/packages/holzleitner_api/lib/src/model/sync_contact_channel.g.dart new file mode 100644 index 0000000..f81a8a8 --- /dev/null +++ b/packages/holzleitner_api/lib/src/model/sync_contact_channel.g.dart @@ -0,0 +1,121 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'sync_contact_channel.dart'; + +// ************************************************************************** +// BuiltValueGenerator +// ************************************************************************** + +class _$SyncContactChannel extends SyncContactChannel { + @override + final ContactKind kind; + @override + final int position; + @override + final String value; + + factory _$SyncContactChannel( + [void Function(SyncContactChannelBuilder)? updates]) => + (SyncContactChannelBuilder()..update(updates))._build(); + + _$SyncContactChannel._( + {required this.kind, required this.position, required this.value}) + : super._(); + @override + SyncContactChannel rebuild( + void Function(SyncContactChannelBuilder) updates) => + (toBuilder()..update(updates)).build(); + + @override + SyncContactChannelBuilder toBuilder() => + SyncContactChannelBuilder()..replace(this); + + @override + bool operator ==(Object other) { + if (identical(other, this)) return true; + return other is SyncContactChannel && + kind == other.kind && + position == other.position && + value == other.value; + } + + @override + int get hashCode { + var _$hash = 0; + _$hash = $jc(_$hash, kind.hashCode); + _$hash = $jc(_$hash, position.hashCode); + _$hash = $jc(_$hash, value.hashCode); + _$hash = $jf(_$hash); + return _$hash; + } + + @override + String toString() { + return (newBuiltValueToStringHelper(r'SyncContactChannel') + ..add('kind', kind) + ..add('position', position) + ..add('value', value)) + .toString(); + } +} + +class SyncContactChannelBuilder + implements Builder { + _$SyncContactChannel? _$v; + + ContactKind? _kind; + ContactKind? get kind => _$this._kind; + set kind(ContactKind? kind) => _$this._kind = kind; + + int? _position; + int? get position => _$this._position; + set position(int? position) => _$this._position = position; + + String? _value; + String? get value => _$this._value; + set value(String? value) => _$this._value = value; + + SyncContactChannelBuilder() { + SyncContactChannel._defaults(this); + } + + SyncContactChannelBuilder get _$this { + final $v = _$v; + if ($v != null) { + _kind = $v.kind; + _position = $v.position; + _value = $v.value; + _$v = null; + } + return this; + } + + @override + void replace(SyncContactChannel other) { + _$v = other as _$SyncContactChannel; + } + + @override + void update(void Function(SyncContactChannelBuilder)? updates) { + if (updates != null) updates(this); + } + + @override + SyncContactChannel build() => _build(); + + _$SyncContactChannel _build() { + final _$result = _$v ?? + _$SyncContactChannel._( + kind: BuiltValueNullFieldError.checkNotNull( + kind, r'SyncContactChannel', 'kind'), + position: BuiltValueNullFieldError.checkNotNull( + position, r'SyncContactChannel', 'position'), + value: BuiltValueNullFieldError.checkNotNull( + value, r'SyncContactChannel', 'value'), + ); + replace(_$result); + return _$result; + } +} + +// ignore_for_file: deprecated_member_use_from_same_package,type=lint diff --git a/packages/holzleitner_api/lib/src/model/sync_contact_source.dart b/packages/holzleitner_api/lib/src/model/sync_contact_source.dart new file mode 100644 index 0000000..350f1c6 --- /dev/null +++ b/packages/holzleitner_api/lib/src/model/sync_contact_source.dart @@ -0,0 +1,261 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// + +// ignore_for_file: unused_element +import 'package:holzleitner_api/src/model/contact_role.dart'; +import 'package:built_collection/built_collection.dart'; +import 'package:holzleitner_api/src/model/sync_contact_channel.dart'; +import 'package:built_value/built_value.dart'; +import 'package:built_value/serializer.dart'; + +part 'sync_contact_source.g.dart'; + +/// Eine Adress-Rolle eines Belegs mit Namensblock und allen ausgefüllten Telefon-/Mobil-/E-Mail-/Web-Einträgen. +/// +/// Properties: +/// * [abteilung] +/// * [anrede] +/// * [channels] +/// * [funktion] +/// * [name1] +/// * [name2] +/// * [name3] +/// * [role] +/// * [titel] +@BuiltValue() +abstract class SyncContactSource implements Built { + @BuiltValueField(wireName: r'abteilung') + String? get abteilung; + + @BuiltValueField(wireName: r'anrede') + String? get anrede; + + @BuiltValueField(wireName: r'channels') + BuiltList? get channels; + + @BuiltValueField(wireName: r'funktion') + String? get funktion; + + @BuiltValueField(wireName: r'name1') + String? get name1; + + @BuiltValueField(wireName: r'name2') + String? get name2; + + @BuiltValueField(wireName: r'name3') + String? get name3; + + @BuiltValueField(wireName: r'role') + ContactRole get role; + // enum roleEnum { header, delivery, billing, contact_person, customer_master, }; + + @BuiltValueField(wireName: r'titel') + String? get titel; + + SyncContactSource._(); + + factory SyncContactSource([void updates(SyncContactSourceBuilder b)]) = _$SyncContactSource; + + @BuiltValueHook(initializeBuilder: true) + static void _defaults(SyncContactSourceBuilder b) => b; + + @BuiltValueSerializer(custom: true) + static Serializer get serializer => _$SyncContactSourceSerializer(); +} + +class _$SyncContactSourceSerializer implements PrimitiveSerializer { + @override + final Iterable types = const [SyncContactSource, _$SyncContactSource]; + + @override + final String wireName = r'SyncContactSource'; + + Iterable _serializeProperties( + Serializers serializers, + SyncContactSource object, { + FullType specifiedType = FullType.unspecified, + }) sync* { + if (object.abteilung != null) { + yield r'abteilung'; + yield serializers.serialize( + object.abteilung, + specifiedType: const FullType.nullable(String), + ); + } + if (object.anrede != null) { + yield r'anrede'; + yield serializers.serialize( + object.anrede, + specifiedType: const FullType.nullable(String), + ); + } + if (object.channels != null) { + yield r'channels'; + yield serializers.serialize( + object.channels, + specifiedType: const FullType(BuiltList, [FullType(SyncContactChannel)]), + ); + } + if (object.funktion != null) { + yield r'funktion'; + yield serializers.serialize( + object.funktion, + specifiedType: const FullType.nullable(String), + ); + } + if (object.name1 != null) { + yield r'name1'; + yield serializers.serialize( + object.name1, + specifiedType: const FullType.nullable(String), + ); + } + if (object.name2 != null) { + yield r'name2'; + yield serializers.serialize( + object.name2, + specifiedType: const FullType.nullable(String), + ); + } + if (object.name3 != null) { + yield r'name3'; + yield serializers.serialize( + object.name3, + specifiedType: const FullType.nullable(String), + ); + } + yield r'role'; + yield serializers.serialize( + object.role, + specifiedType: const FullType(ContactRole), + ); + if (object.titel != null) { + yield r'titel'; + yield serializers.serialize( + object.titel, + specifiedType: const FullType.nullable(String), + ); + } + } + + @override + Object serialize( + Serializers serializers, + SyncContactSource object, { + FullType specifiedType = FullType.unspecified, + }) { + return _serializeProperties(serializers, object, specifiedType: specifiedType).toList(); + } + + void _deserializeProperties( + Serializers serializers, + Object serialized, { + FullType specifiedType = FullType.unspecified, + required List serializedList, + required SyncContactSourceBuilder result, + required List unhandled, + }) { + for (var i = 0; i < serializedList.length; i += 2) { + final key = serializedList[i] as String; + final value = serializedList[i + 1]; + switch (key) { + case r'abteilung': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType.nullable(String), + ) as String?; + if (valueDes == null) continue; + result.abteilung = valueDes; + break; + case r'anrede': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType.nullable(String), + ) as String?; + if (valueDes == null) continue; + result.anrede = valueDes; + break; + case r'channels': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType(BuiltList, [FullType(SyncContactChannel)]), + ) as BuiltList; + result.channels.replace(valueDes); + break; + case r'funktion': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType.nullable(String), + ) as String?; + if (valueDes == null) continue; + result.funktion = valueDes; + break; + case r'name1': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType.nullable(String), + ) as String?; + if (valueDes == null) continue; + result.name1 = valueDes; + break; + case r'name2': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType.nullable(String), + ) as String?; + if (valueDes == null) continue; + result.name2 = valueDes; + break; + case r'name3': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType.nullable(String), + ) as String?; + if (valueDes == null) continue; + result.name3 = valueDes; + break; + case r'role': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType(ContactRole), + ) as ContactRole; + result.role = valueDes; + break; + case r'titel': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType.nullable(String), + ) as String?; + if (valueDes == null) continue; + result.titel = valueDes; + break; + default: + unhandled.add(key); + unhandled.add(value); + break; + } + } + } + + @override + SyncContactSource deserialize( + Serializers serializers, + Object serialized, { + FullType specifiedType = FullType.unspecified, + }) { + final result = SyncContactSourceBuilder(); + final serializedList = (serialized as Iterable).toList(); + final unhandled = []; + _deserializeProperties( + serializers, + serialized, + specifiedType: specifiedType, + serializedList: serializedList, + unhandled: unhandled, + result: result, + ); + return result.build(); + } +} + diff --git a/packages/holzleitner_api/lib/src/model/sync_contact_source.g.dart b/packages/holzleitner_api/lib/src/model/sync_contact_source.g.dart new file mode 100644 index 0000000..e28e97d --- /dev/null +++ b/packages/holzleitner_api/lib/src/model/sync_contact_source.g.dart @@ -0,0 +1,207 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'sync_contact_source.dart'; + +// ************************************************************************** +// BuiltValueGenerator +// ************************************************************************** + +class _$SyncContactSource extends SyncContactSource { + @override + final String? abteilung; + @override + final String? anrede; + @override + final BuiltList? channels; + @override + final String? funktion; + @override + final String? name1; + @override + final String? name2; + @override + final String? name3; + @override + final ContactRole role; + @override + final String? titel; + + factory _$SyncContactSource( + [void Function(SyncContactSourceBuilder)? updates]) => + (SyncContactSourceBuilder()..update(updates))._build(); + + _$SyncContactSource._( + {this.abteilung, + this.anrede, + this.channels, + this.funktion, + this.name1, + this.name2, + this.name3, + required this.role, + this.titel}) + : super._(); + @override + SyncContactSource rebuild(void Function(SyncContactSourceBuilder) updates) => + (toBuilder()..update(updates)).build(); + + @override + SyncContactSourceBuilder toBuilder() => + SyncContactSourceBuilder()..replace(this); + + @override + bool operator ==(Object other) { + if (identical(other, this)) return true; + return other is SyncContactSource && + abteilung == other.abteilung && + anrede == other.anrede && + channels == other.channels && + funktion == other.funktion && + name1 == other.name1 && + name2 == other.name2 && + name3 == other.name3 && + role == other.role && + titel == other.titel; + } + + @override + int get hashCode { + var _$hash = 0; + _$hash = $jc(_$hash, abteilung.hashCode); + _$hash = $jc(_$hash, anrede.hashCode); + _$hash = $jc(_$hash, channels.hashCode); + _$hash = $jc(_$hash, funktion.hashCode); + _$hash = $jc(_$hash, name1.hashCode); + _$hash = $jc(_$hash, name2.hashCode); + _$hash = $jc(_$hash, name3.hashCode); + _$hash = $jc(_$hash, role.hashCode); + _$hash = $jc(_$hash, titel.hashCode); + _$hash = $jf(_$hash); + return _$hash; + } + + @override + String toString() { + return (newBuiltValueToStringHelper(r'SyncContactSource') + ..add('abteilung', abteilung) + ..add('anrede', anrede) + ..add('channels', channels) + ..add('funktion', funktion) + ..add('name1', name1) + ..add('name2', name2) + ..add('name3', name3) + ..add('role', role) + ..add('titel', titel)) + .toString(); + } +} + +class SyncContactSourceBuilder + implements Builder { + _$SyncContactSource? _$v; + + String? _abteilung; + String? get abteilung => _$this._abteilung; + set abteilung(String? abteilung) => _$this._abteilung = abteilung; + + String? _anrede; + String? get anrede => _$this._anrede; + set anrede(String? anrede) => _$this._anrede = anrede; + + ListBuilder? _channels; + ListBuilder get channels => + _$this._channels ??= ListBuilder(); + set channels(ListBuilder? channels) => + _$this._channels = channels; + + String? _funktion; + String? get funktion => _$this._funktion; + set funktion(String? funktion) => _$this._funktion = funktion; + + String? _name1; + String? get name1 => _$this._name1; + set name1(String? name1) => _$this._name1 = name1; + + String? _name2; + String? get name2 => _$this._name2; + set name2(String? name2) => _$this._name2 = name2; + + String? _name3; + String? get name3 => _$this._name3; + set name3(String? name3) => _$this._name3 = name3; + + ContactRole? _role; + ContactRole? get role => _$this._role; + set role(ContactRole? role) => _$this._role = role; + + String? _titel; + String? get titel => _$this._titel; + set titel(String? titel) => _$this._titel = titel; + + SyncContactSourceBuilder() { + SyncContactSource._defaults(this); + } + + SyncContactSourceBuilder get _$this { + final $v = _$v; + if ($v != null) { + _abteilung = $v.abteilung; + _anrede = $v.anrede; + _channels = $v.channels?.toBuilder(); + _funktion = $v.funktion; + _name1 = $v.name1; + _name2 = $v.name2; + _name3 = $v.name3; + _role = $v.role; + _titel = $v.titel; + _$v = null; + } + return this; + } + + @override + void replace(SyncContactSource other) { + _$v = other as _$SyncContactSource; + } + + @override + void update(void Function(SyncContactSourceBuilder)? updates) { + if (updates != null) updates(this); + } + + @override + SyncContactSource build() => _build(); + + _$SyncContactSource _build() { + _$SyncContactSource _$result; + try { + _$result = _$v ?? + _$SyncContactSource._( + abteilung: abteilung, + anrede: anrede, + channels: _channels?.build(), + funktion: funktion, + name1: name1, + name2: name2, + name3: name3, + role: BuiltValueNullFieldError.checkNotNull( + role, r'SyncContactSource', 'role'), + titel: titel, + ); + } catch (_) { + late String _$failedField; + try { + _$failedField = 'channels'; + _channels?.build(); + } catch (e) { + throw BuiltValueNestedFieldError( + r'SyncContactSource', _$failedField, e.toString()); + } + rethrow; + } + replace(_$result); + return _$result; + } +} + +// ignore_for_file: deprecated_member_use_from_same_package,type=lint diff --git a/packages/holzleitner_api/lib/src/model/sync_delivery.dart b/packages/holzleitner_api/lib/src/model/sync_delivery.dart index c412e7e..383ba10 100644 --- a/packages/holzleitner_api/lib/src/model/sync_delivery.dart +++ b/packages/holzleitner_api/lib/src/model/sync_delivery.dart @@ -4,6 +4,7 @@ // ignore_for_file: unused_element import 'package:holzleitner_api/src/model/sync_delivery_item.dart'; +import 'package:holzleitner_api/src/model/sync_contact_source.dart'; import 'package:holzleitner_api/src/model/address.dart'; import 'package:built_collection/built_collection.dart'; import 'package:built_value/built_value.dart'; @@ -14,24 +15,41 @@ part 'sync_delivery.g.dart'; /// SyncDelivery /// /// Properties: +/// * [belegartCode] - Belegart-Kurzcode (z. B. „VL5\"), aus `Belegarten.Belegart` (getrimmt). /// * [belegartId] +/// * [belegartName] - Belegart-Klartext (z. B. „Lieferschein EH\"), aus `Belegarten.Bezeichnung`. /// * [belegnummer] +/// * [contactSources] - Alle vom ERP an diesem Beleg hängenden Kontakt-Adressen (Beleg-/ Liefer-/Rechnungsadresse, Ansprechpartner, Kundenstamm). Leere Quellen (kein einziger ausgefüllter Kanal *und* kein Name) lässt der Sync weg. /// * [customerAddress] /// * [customerName] /// * [deliveryAddress] - Snapshot der Lieferadresse (kann von der Stammadresse abweichen). /// * [desiredTime] /// * [erpCustomerId] /// * [items] +/// * [paymentMethodCode] - Für den Restbetrag gewählte Zahlungsart — Referenz per `code` (z. B. `\"cash\"`, `\"invoice\"`). Das ERP kennt seine Standard-Codes, der Sync-Code resolvet sie zur UUID. Wenn `None`, fällt der Backend-Code auf `\"cash\"` zurück. +/// * [prepaidAmount] - Bei Bestellung schon bezahlter Betrag in EUR. Default `0.0`, wenn der Kunde alles bei Lieferung zahlt. Der ERP-Sync liefert den Wert mit. /// * [sortOrder] - 1-basiert, definiert die initiale Reihenfolge in der App. /// * [specialAgreements] @BuiltValue() abstract class SyncDelivery implements Built { + /// Belegart-Kurzcode (z. B. „VL5\"), aus `Belegarten.Belegart` (getrimmt). + @BuiltValueField(wireName: r'belegartCode') + String? get belegartCode; + @BuiltValueField(wireName: r'belegartId') int get belegartId; + /// Belegart-Klartext (z. B. „Lieferschein EH\"), aus `Belegarten.Bezeichnung`. + @BuiltValueField(wireName: r'belegartName') + String? get belegartName; + @BuiltValueField(wireName: r'belegnummer') String get belegnummer; + /// Alle vom ERP an diesem Beleg hängenden Kontakt-Adressen (Beleg-/ Liefer-/Rechnungsadresse, Ansprechpartner, Kundenstamm). Leere Quellen (kein einziger ausgefüllter Kanal *und* kein Name) lässt der Sync weg. + @BuiltValueField(wireName: r'contactSources') + BuiltList? get contactSources; + @BuiltValueField(wireName: r'customerAddress') Address get customerAddress; @@ -51,6 +69,14 @@ abstract class SyncDelivery implements Built @BuiltValueField(wireName: r'items') BuiltList get items; + /// Für den Restbetrag gewählte Zahlungsart — Referenz per `code` (z. B. `\"cash\"`, `\"invoice\"`). Das ERP kennt seine Standard-Codes, der Sync-Code resolvet sie zur UUID. Wenn `None`, fällt der Backend-Code auf `\"cash\"` zurück. + @BuiltValueField(wireName: r'paymentMethodCode') + String? get paymentMethodCode; + + /// Bei Bestellung schon bezahlter Betrag in EUR. Default `0.0`, wenn der Kunde alles bei Lieferung zahlt. Der ERP-Sync liefert den Wert mit. + @BuiltValueField(wireName: r'prepaidAmount') + double? get prepaidAmount; + /// 1-basiert, definiert die initiale Reihenfolge in der App. @BuiltValueField(wireName: r'sortOrder') int get sortOrder; @@ -81,16 +107,37 @@ class _$SyncDeliverySerializer implements PrimitiveSerializer { SyncDelivery object, { FullType specifiedType = FullType.unspecified, }) sync* { + if (object.belegartCode != null) { + yield r'belegartCode'; + yield serializers.serialize( + object.belegartCode, + specifiedType: const FullType.nullable(String), + ); + } yield r'belegartId'; yield serializers.serialize( object.belegartId, specifiedType: const FullType(int), ); + if (object.belegartName != null) { + yield r'belegartName'; + yield serializers.serialize( + object.belegartName, + specifiedType: const FullType.nullable(String), + ); + } yield r'belegnummer'; yield serializers.serialize( object.belegnummer, specifiedType: const FullType(String), ); + if (object.contactSources != null) { + yield r'contactSources'; + yield serializers.serialize( + object.contactSources, + specifiedType: const FullType(BuiltList, [FullType(SyncContactSource)]), + ); + } yield r'customerAddress'; yield serializers.serialize( object.customerAddress, @@ -123,6 +170,20 @@ class _$SyncDeliverySerializer implements PrimitiveSerializer { object.items, specifiedType: const FullType(BuiltList, [FullType(SyncDeliveryItem)]), ); + if (object.paymentMethodCode != null) { + yield r'paymentMethodCode'; + yield serializers.serialize( + object.paymentMethodCode, + specifiedType: const FullType.nullable(String), + ); + } + if (object.prepaidAmount != null) { + yield r'prepaidAmount'; + yield serializers.serialize( + object.prepaidAmount, + specifiedType: const FullType(double), + ); + } yield r'sortOrder'; yield serializers.serialize( object.sortOrder, @@ -158,6 +219,14 @@ class _$SyncDeliverySerializer implements PrimitiveSerializer { final key = serializedList[i] as String; final value = serializedList[i + 1]; switch (key) { + case r'belegartCode': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType.nullable(String), + ) as String?; + if (valueDes == null) continue; + result.belegartCode = valueDes; + break; case r'belegartId': final valueDes = serializers.deserialize( value, @@ -165,6 +234,14 @@ class _$SyncDeliverySerializer implements PrimitiveSerializer { ) as int; result.belegartId = valueDes; break; + case r'belegartName': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType.nullable(String), + ) as String?; + if (valueDes == null) continue; + result.belegartName = valueDes; + break; case r'belegnummer': final valueDes = serializers.deserialize( value, @@ -172,6 +249,13 @@ class _$SyncDeliverySerializer implements PrimitiveSerializer { ) as String; result.belegnummer = valueDes; break; + case r'contactSources': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType(BuiltList, [FullType(SyncContactSource)]), + ) as BuiltList; + result.contactSources.replace(valueDes); + break; case r'customerAddress': final valueDes = serializers.deserialize( value, @@ -215,6 +299,21 @@ class _$SyncDeliverySerializer implements PrimitiveSerializer { ) as BuiltList; result.items.replace(valueDes); break; + case r'paymentMethodCode': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType.nullable(String), + ) as String?; + if (valueDes == null) continue; + result.paymentMethodCode = valueDes; + break; + case r'prepaidAmount': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType(double), + ) as double; + result.prepaidAmount = valueDes; + break; case r'sortOrder': final valueDes = serializers.deserialize( value, diff --git a/packages/holzleitner_api/lib/src/model/sync_delivery.g.dart b/packages/holzleitner_api/lib/src/model/sync_delivery.g.dart index 7d9c1b2..33d4404 100644 --- a/packages/holzleitner_api/lib/src/model/sync_delivery.g.dart +++ b/packages/holzleitner_api/lib/src/model/sync_delivery.g.dart @@ -7,11 +7,17 @@ part of 'sync_delivery.dart'; // ************************************************************************** class _$SyncDelivery extends SyncDelivery { + @override + final String? belegartCode; @override final int belegartId; @override + final String? belegartName; + @override final String belegnummer; @override + final BuiltList? contactSources; + @override final Address customerAddress; @override final String customerName; @@ -24,6 +30,10 @@ class _$SyncDelivery extends SyncDelivery { @override final BuiltList items; @override + final String? paymentMethodCode; + @override + final double? prepaidAmount; + @override final int sortOrder; @override final String? specialAgreements; @@ -32,14 +42,19 @@ class _$SyncDelivery extends SyncDelivery { (SyncDeliveryBuilder()..update(updates))._build(); _$SyncDelivery._( - {required this.belegartId, + {this.belegartCode, + required this.belegartId, + this.belegartName, required this.belegnummer, + this.contactSources, required this.customerAddress, required this.customerName, required this.deliveryAddress, this.desiredTime, required this.erpCustomerId, required this.items, + this.paymentMethodCode, + this.prepaidAmount, required this.sortOrder, this.specialAgreements}) : super._(); @@ -54,14 +69,19 @@ class _$SyncDelivery extends SyncDelivery { bool operator ==(Object other) { if (identical(other, this)) return true; return other is SyncDelivery && + belegartCode == other.belegartCode && belegartId == other.belegartId && + belegartName == other.belegartName && belegnummer == other.belegnummer && + contactSources == other.contactSources && customerAddress == other.customerAddress && customerName == other.customerName && deliveryAddress == other.deliveryAddress && desiredTime == other.desiredTime && erpCustomerId == other.erpCustomerId && items == other.items && + paymentMethodCode == other.paymentMethodCode && + prepaidAmount == other.prepaidAmount && sortOrder == other.sortOrder && specialAgreements == other.specialAgreements; } @@ -69,14 +89,19 @@ class _$SyncDelivery extends SyncDelivery { @override int get hashCode { var _$hash = 0; + _$hash = $jc(_$hash, belegartCode.hashCode); _$hash = $jc(_$hash, belegartId.hashCode); + _$hash = $jc(_$hash, belegartName.hashCode); _$hash = $jc(_$hash, belegnummer.hashCode); + _$hash = $jc(_$hash, contactSources.hashCode); _$hash = $jc(_$hash, customerAddress.hashCode); _$hash = $jc(_$hash, customerName.hashCode); _$hash = $jc(_$hash, deliveryAddress.hashCode); _$hash = $jc(_$hash, desiredTime.hashCode); _$hash = $jc(_$hash, erpCustomerId.hashCode); _$hash = $jc(_$hash, items.hashCode); + _$hash = $jc(_$hash, paymentMethodCode.hashCode); + _$hash = $jc(_$hash, prepaidAmount.hashCode); _$hash = $jc(_$hash, sortOrder.hashCode); _$hash = $jc(_$hash, specialAgreements.hashCode); _$hash = $jf(_$hash); @@ -86,14 +111,19 @@ class _$SyncDelivery extends SyncDelivery { @override String toString() { return (newBuiltValueToStringHelper(r'SyncDelivery') + ..add('belegartCode', belegartCode) ..add('belegartId', belegartId) + ..add('belegartName', belegartName) ..add('belegnummer', belegnummer) + ..add('contactSources', contactSources) ..add('customerAddress', customerAddress) ..add('customerName', customerName) ..add('deliveryAddress', deliveryAddress) ..add('desiredTime', desiredTime) ..add('erpCustomerId', erpCustomerId) ..add('items', items) + ..add('paymentMethodCode', paymentMethodCode) + ..add('prepaidAmount', prepaidAmount) ..add('sortOrder', sortOrder) ..add('specialAgreements', specialAgreements)) .toString(); @@ -104,14 +134,28 @@ class SyncDeliveryBuilder implements Builder { _$SyncDelivery? _$v; + String? _belegartCode; + String? get belegartCode => _$this._belegartCode; + set belegartCode(String? belegartCode) => _$this._belegartCode = belegartCode; + int? _belegartId; int? get belegartId => _$this._belegartId; set belegartId(int? belegartId) => _$this._belegartId = belegartId; + String? _belegartName; + String? get belegartName => _$this._belegartName; + set belegartName(String? belegartName) => _$this._belegartName = belegartName; + String? _belegnummer; String? get belegnummer => _$this._belegnummer; set belegnummer(String? belegnummer) => _$this._belegnummer = belegnummer; + ListBuilder? _contactSources; + ListBuilder get contactSources => + _$this._contactSources ??= ListBuilder(); + set contactSources(ListBuilder? contactSources) => + _$this._contactSources = contactSources; + AddressBuilder? _customerAddress; AddressBuilder get customerAddress => _$this._customerAddress ??= AddressBuilder(); @@ -142,6 +186,16 @@ class SyncDeliveryBuilder _$this._items ??= ListBuilder(); set items(ListBuilder? items) => _$this._items = items; + String? _paymentMethodCode; + String? get paymentMethodCode => _$this._paymentMethodCode; + set paymentMethodCode(String? paymentMethodCode) => + _$this._paymentMethodCode = paymentMethodCode; + + double? _prepaidAmount; + double? get prepaidAmount => _$this._prepaidAmount; + set prepaidAmount(double? prepaidAmount) => + _$this._prepaidAmount = prepaidAmount; + int? _sortOrder; int? get sortOrder => _$this._sortOrder; set sortOrder(int? sortOrder) => _$this._sortOrder = sortOrder; @@ -158,14 +212,19 @@ class SyncDeliveryBuilder SyncDeliveryBuilder get _$this { final $v = _$v; if ($v != null) { + _belegartCode = $v.belegartCode; _belegartId = $v.belegartId; + _belegartName = $v.belegartName; _belegnummer = $v.belegnummer; + _contactSources = $v.contactSources?.toBuilder(); _customerAddress = $v.customerAddress.toBuilder(); _customerName = $v.customerName; _deliveryAddress = $v.deliveryAddress.toBuilder(); _desiredTime = $v.desiredTime; _erpCustomerId = $v.erpCustomerId; _items = $v.items.toBuilder(); + _paymentMethodCode = $v.paymentMethodCode; + _prepaidAmount = $v.prepaidAmount; _sortOrder = $v.sortOrder; _specialAgreements = $v.specialAgreements; _$v = null; @@ -191,10 +250,13 @@ class SyncDeliveryBuilder try { _$result = _$v ?? _$SyncDelivery._( + belegartCode: belegartCode, belegartId: BuiltValueNullFieldError.checkNotNull( belegartId, r'SyncDelivery', 'belegartId'), + belegartName: belegartName, belegnummer: BuiltValueNullFieldError.checkNotNull( belegnummer, r'SyncDelivery', 'belegnummer'), + contactSources: _contactSources?.build(), customerAddress: customerAddress.build(), customerName: BuiltValueNullFieldError.checkNotNull( customerName, r'SyncDelivery', 'customerName'), @@ -203,6 +265,8 @@ class SyncDeliveryBuilder erpCustomerId: BuiltValueNullFieldError.checkNotNull( erpCustomerId, r'SyncDelivery', 'erpCustomerId'), items: items.build(), + paymentMethodCode: paymentMethodCode, + prepaidAmount: prepaidAmount, sortOrder: BuiltValueNullFieldError.checkNotNull( sortOrder, r'SyncDelivery', 'sortOrder'), specialAgreements: specialAgreements, @@ -210,6 +274,8 @@ class SyncDeliveryBuilder } catch (_) { late String _$failedField; try { + _$failedField = 'contactSources'; + _contactSources?.build(); _$failedField = 'customerAddress'; customerAddress.build(); diff --git a/packages/holzleitner_api/lib/src/model/sync_delivery_item.dart b/packages/holzleitner_api/lib/src/model/sync_delivery_item.dart index 27d42fb..0ed3fe4 100644 --- a/packages/holzleitner_api/lib/src/model/sync_delivery_item.dart +++ b/packages/holzleitner_api/lib/src/model/sync_delivery_item.dart @@ -16,8 +16,10 @@ part 'sync_delivery_item.g.dart'; /// * [articleNumber] /// * [articleScannable] /// * [belegzeilenNr] -/// * [komponentenArtikelNr] - Komponenten-Artikelnummer bei aufgelösten Stücklisten, sonst leer. +/// * [komponentenArtikelNr] - Komponenten-Artikelnummer bei aufgelösten Stücklisten, sonst leer. Trägt die **eigene** Nummer der Komponente (eindeutig je Belegzeile). +/// * [parentArtikelNr] - Artikelnummer des **Oberartikels**, zu dem diese Komponente gehört. `None` bei Oberartikeln/regulären Zeilen. Erlaubt der App, Komponenten unter ihrem Oberartikel einzurücken. /// * [requiredQuantity] +/// * [unitPrice] - Stückpreis (brutto, EUR). Default `0.0`. Liefert der ERP-Sync mit; die App rechnet daraus den Warenwert. /// * [warehouseCode] /// * [warehouseName] @BuiltValue() @@ -38,13 +40,21 @@ abstract class SyncDeliveryItem implements Built _$this._komponentenArtikelNr = komponentenArtikelNr; + String? _parentArtikelNr; + String? get parentArtikelNr => _$this._parentArtikelNr; + set parentArtikelNr(String? parentArtikelNr) => + _$this._parentArtikelNr = parentArtikelNr; + int? _requiredQuantity; int? get requiredQuantity => _$this._requiredQuantity; set requiredQuantity(int? requiredQuantity) => _$this._requiredQuantity = requiredQuantity; + double? _unitPrice; + double? get unitPrice => _$this._unitPrice; + set unitPrice(double? unitPrice) => _$this._unitPrice = unitPrice; + String? _warehouseCode; String? get warehouseCode => _$this._warehouseCode; set warehouseCode(String? warehouseCode) => @@ -158,7 +179,9 @@ class SyncDeliveryItemBuilder _articleScannable = $v.articleScannable; _belegzeilenNr = $v.belegzeilenNr; _komponentenArtikelNr = $v.komponentenArtikelNr; + _parentArtikelNr = $v.parentArtikelNr; _requiredQuantity = $v.requiredQuantity; + _unitPrice = $v.unitPrice; _warehouseCode = $v.warehouseCode; _warehouseName = $v.warehouseName; _$v = null; @@ -192,8 +215,10 @@ class SyncDeliveryItemBuilder belegzeilenNr: BuiltValueNullFieldError.checkNotNull( belegzeilenNr, r'SyncDeliveryItem', 'belegzeilenNr'), komponentenArtikelNr: komponentenArtikelNr, + parentArtikelNr: parentArtikelNr, requiredQuantity: BuiltValueNullFieldError.checkNotNull( requiredQuantity, r'SyncDeliveryItem', 'requiredQuantity'), + unitPrice: unitPrice, warehouseCode: BuiltValueNullFieldError.checkNotNull( warehouseCode, r'SyncDeliveryItem', 'warehouseCode'), warehouseName: BuiltValueNullFieldError.checkNotNull( diff --git a/packages/holzleitner_api/lib/src/model/tour_details.dart b/packages/holzleitner_api/lib/src/model/tour_details.dart index 39b2df3..9378e85 100644 --- a/packages/holzleitner_api/lib/src/model/tour_details.dart +++ b/packages/holzleitner_api/lib/src/model/tour_details.dart @@ -3,14 +3,19 @@ // // ignore_for_file: unused_element +import 'package:holzleitner_api/src/model/delivery_credit.dart'; +import 'package:holzleitner_api/src/model/delivery_note.dart'; +import 'package:holzleitner_api/src/model/delivery_with_items.dart'; +import 'package:holzleitner_api/src/model/article.dart'; +import 'package:holzleitner_api/src/model/contact_channel.dart'; import 'package:holzleitner_api/src/model/customer.dart'; +import 'package:holzleitner_api/src/model/contact_source.dart'; import 'package:holzleitner_api/src/model/tour.dart'; import 'package:holzleitner_api/src/model/warehouse.dart'; import 'package:built_collection/built_collection.dart'; import 'package:holzleitner_api/src/model/customer_contact.dart'; -import 'package:holzleitner_api/src/model/delivery_note.dart'; -import 'package:holzleitner_api/src/model/delivery_with_items.dart'; -import 'package:holzleitner_api/src/model/article.dart'; +import 'package:holzleitner_api/src/model/service.dart'; +import 'package:holzleitner_api/src/model/delivery_service_value.dart'; import 'package:built_value/built_value.dart'; import 'package:built_value/serializer.dart'; @@ -20,10 +25,15 @@ part 'tour_details.g.dart'; /// /// Properties: /// * [articles] +/// * [contactChannels] - Die zu `contact_sources` gehörenden Einzel-Kanäle (Telefon, Mobil, E-Mail, Web). Join per `source_id`. +/// * [contactSources] - Alle vom ERP gespiegelten Kontaktquellen aller Lieferungen dieser Tour. Die App joint clientseitig per `delivery_id` und gruppiert nach `role` (Lieferadresse / Rechnungsadresse / Ansprechpartner / Kundenstamm / Belegadresse). +/// * [credits] - Aktuelle Betrags-Gutschriften (jüngster Stand pro Lieferung), nur für Lieferungen, deren letztes Ereignis `set` war. Join per `delivery_id`. /// * [customerContacts] /// * [customers] /// * [deliveries] +/// * [deliveryServices] - Pro-Lieferung gesetzte Service-Werte. Join per `delivery_id` + `service_id`. /// * [notes] - Alle Notizen aller Lieferungen dieser Tour, in einer Liste. Die App joint clientseitig per `delivery_id`. Reihenfolge: pro Lieferung aufsteigend nach `created_at`. +/// * [services] - Aktive Service-Definitionen (Stammdaten) — die App rendert daraus Phase 4. Bewusst hier mitgeliefert, damit die Detailseite alles aus dem Tour-Aggregat hat. /// * [tour] /// * [warehouses] @BuiltValue() @@ -31,6 +41,18 @@ abstract class TourDetails implements Built { @BuiltValueField(wireName: r'articles') BuiltList
get articles; + /// Die zu `contact_sources` gehörenden Einzel-Kanäle (Telefon, Mobil, E-Mail, Web). Join per `source_id`. + @BuiltValueField(wireName: r'contactChannels') + BuiltList get contactChannels; + + /// Alle vom ERP gespiegelten Kontaktquellen aller Lieferungen dieser Tour. Die App joint clientseitig per `delivery_id` und gruppiert nach `role` (Lieferadresse / Rechnungsadresse / Ansprechpartner / Kundenstamm / Belegadresse). + @BuiltValueField(wireName: r'contactSources') + BuiltList get contactSources; + + /// Aktuelle Betrags-Gutschriften (jüngster Stand pro Lieferung), nur für Lieferungen, deren letztes Ereignis `set` war. Join per `delivery_id`. + @BuiltValueField(wireName: r'credits') + BuiltList get credits; + @BuiltValueField(wireName: r'customerContacts') BuiltList get customerContacts; @@ -40,10 +62,18 @@ abstract class TourDetails implements Built { @BuiltValueField(wireName: r'deliveries') BuiltList get deliveries; + /// Pro-Lieferung gesetzte Service-Werte. Join per `delivery_id` + `service_id`. + @BuiltValueField(wireName: r'deliveryServices') + BuiltList get deliveryServices; + /// Alle Notizen aller Lieferungen dieser Tour, in einer Liste. Die App joint clientseitig per `delivery_id`. Reihenfolge: pro Lieferung aufsteigend nach `created_at`. @BuiltValueField(wireName: r'notes') BuiltList get notes; + /// Aktive Service-Definitionen (Stammdaten) — die App rendert daraus Phase 4. Bewusst hier mitgeliefert, damit die Detailseite alles aus dem Tour-Aggregat hat. + @BuiltValueField(wireName: r'services') + BuiltList get services; + @BuiltValueField(wireName: r'tour') Tour get tour; @@ -78,6 +108,21 @@ class _$TourDetailsSerializer implements PrimitiveSerializer { object.articles, specifiedType: const FullType(BuiltList, [FullType(Article)]), ); + yield r'contactChannels'; + yield serializers.serialize( + object.contactChannels, + specifiedType: const FullType(BuiltList, [FullType(ContactChannel)]), + ); + yield r'contactSources'; + yield serializers.serialize( + object.contactSources, + specifiedType: const FullType(BuiltList, [FullType(ContactSource)]), + ); + yield r'credits'; + yield serializers.serialize( + object.credits, + specifiedType: const FullType(BuiltList, [FullType(DeliveryCredit)]), + ); yield r'customerContacts'; yield serializers.serialize( object.customerContacts, @@ -93,11 +138,21 @@ class _$TourDetailsSerializer implements PrimitiveSerializer { object.deliveries, specifiedType: const FullType(BuiltList, [FullType(DeliveryWithItems)]), ); + yield r'deliveryServices'; + yield serializers.serialize( + object.deliveryServices, + specifiedType: const FullType(BuiltList, [FullType(DeliveryServiceValue)]), + ); yield r'notes'; yield serializers.serialize( object.notes, specifiedType: const FullType(BuiltList, [FullType(DeliveryNote)]), ); + yield r'services'; + yield serializers.serialize( + object.services, + specifiedType: const FullType(BuiltList, [FullType(Service)]), + ); yield r'tour'; yield serializers.serialize( object.tour, @@ -138,6 +193,27 @@ class _$TourDetailsSerializer implements PrimitiveSerializer { ) as BuiltList
; result.articles.replace(valueDes); break; + case r'contactChannels': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType(BuiltList, [FullType(ContactChannel)]), + ) as BuiltList; + result.contactChannels.replace(valueDes); + break; + case r'contactSources': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType(BuiltList, [FullType(ContactSource)]), + ) as BuiltList; + result.contactSources.replace(valueDes); + break; + case r'credits': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType(BuiltList, [FullType(DeliveryCredit)]), + ) as BuiltList; + result.credits.replace(valueDes); + break; case r'customerContacts': final valueDes = serializers.deserialize( value, @@ -159,6 +235,13 @@ class _$TourDetailsSerializer implements PrimitiveSerializer { ) as BuiltList; result.deliveries.replace(valueDes); break; + case r'deliveryServices': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType(BuiltList, [FullType(DeliveryServiceValue)]), + ) as BuiltList; + result.deliveryServices.replace(valueDes); + break; case r'notes': final valueDes = serializers.deserialize( value, @@ -166,6 +249,13 @@ class _$TourDetailsSerializer implements PrimitiveSerializer { ) as BuiltList; result.notes.replace(valueDes); break; + case r'services': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType(BuiltList, [FullType(Service)]), + ) as BuiltList; + result.services.replace(valueDes); + break; case r'tour': final valueDes = serializers.deserialize( value, diff --git a/packages/holzleitner_api/lib/src/model/tour_details.g.dart b/packages/holzleitner_api/lib/src/model/tour_details.g.dart index 75d440a..7932481 100644 --- a/packages/holzleitner_api/lib/src/model/tour_details.g.dart +++ b/packages/holzleitner_api/lib/src/model/tour_details.g.dart @@ -10,14 +10,24 @@ class _$TourDetails extends TourDetails { @override final BuiltList
articles; @override + final BuiltList contactChannels; + @override + final BuiltList contactSources; + @override + final BuiltList credits; + @override final BuiltList customerContacts; @override final BuiltList customers; @override final BuiltList deliveries; @override + final BuiltList deliveryServices; + @override final BuiltList notes; @override + final BuiltList services; + @override final Tour tour; @override final BuiltList warehouses; @@ -27,10 +37,15 @@ class _$TourDetails extends TourDetails { _$TourDetails._( {required this.articles, + required this.contactChannels, + required this.contactSources, + required this.credits, required this.customerContacts, required this.customers, required this.deliveries, + required this.deliveryServices, required this.notes, + required this.services, required this.tour, required this.warehouses}) : super._(); @@ -46,10 +61,15 @@ class _$TourDetails extends TourDetails { if (identical(other, this)) return true; return other is TourDetails && articles == other.articles && + contactChannels == other.contactChannels && + contactSources == other.contactSources && + credits == other.credits && customerContacts == other.customerContacts && customers == other.customers && deliveries == other.deliveries && + deliveryServices == other.deliveryServices && notes == other.notes && + services == other.services && tour == other.tour && warehouses == other.warehouses; } @@ -58,10 +78,15 @@ class _$TourDetails extends TourDetails { int get hashCode { var _$hash = 0; _$hash = $jc(_$hash, articles.hashCode); + _$hash = $jc(_$hash, contactChannels.hashCode); + _$hash = $jc(_$hash, contactSources.hashCode); + _$hash = $jc(_$hash, credits.hashCode); _$hash = $jc(_$hash, customerContacts.hashCode); _$hash = $jc(_$hash, customers.hashCode); _$hash = $jc(_$hash, deliveries.hashCode); + _$hash = $jc(_$hash, deliveryServices.hashCode); _$hash = $jc(_$hash, notes.hashCode); + _$hash = $jc(_$hash, services.hashCode); _$hash = $jc(_$hash, tour.hashCode); _$hash = $jc(_$hash, warehouses.hashCode); _$hash = $jf(_$hash); @@ -72,10 +97,15 @@ class _$TourDetails extends TourDetails { String toString() { return (newBuiltValueToStringHelper(r'TourDetails') ..add('articles', articles) + ..add('contactChannels', contactChannels) + ..add('contactSources', contactSources) + ..add('credits', credits) ..add('customerContacts', customerContacts) ..add('customers', customers) ..add('deliveries', deliveries) + ..add('deliveryServices', deliveryServices) ..add('notes', notes) + ..add('services', services) ..add('tour', tour) ..add('warehouses', warehouses)) .toString(); @@ -90,6 +120,24 @@ class TourDetailsBuilder implements Builder { _$this._articles ??= ListBuilder
(); set articles(ListBuilder
? articles) => _$this._articles = articles; + ListBuilder? _contactChannels; + ListBuilder get contactChannels => + _$this._contactChannels ??= ListBuilder(); + set contactChannels(ListBuilder? contactChannels) => + _$this._contactChannels = contactChannels; + + ListBuilder? _contactSources; + ListBuilder get contactSources => + _$this._contactSources ??= ListBuilder(); + set contactSources(ListBuilder? contactSources) => + _$this._contactSources = contactSources; + + ListBuilder? _credits; + ListBuilder get credits => + _$this._credits ??= ListBuilder(); + set credits(ListBuilder? credits) => + _$this._credits = credits; + ListBuilder? _customerContacts; ListBuilder get customerContacts => _$this._customerContacts ??= ListBuilder(); @@ -108,11 +156,22 @@ class TourDetailsBuilder implements Builder { set deliveries(ListBuilder? deliveries) => _$this._deliveries = deliveries; + ListBuilder? _deliveryServices; + ListBuilder get deliveryServices => + _$this._deliveryServices ??= ListBuilder(); + set deliveryServices(ListBuilder? deliveryServices) => + _$this._deliveryServices = deliveryServices; + ListBuilder? _notes; ListBuilder get notes => _$this._notes ??= ListBuilder(); set notes(ListBuilder? notes) => _$this._notes = notes; + ListBuilder? _services; + ListBuilder get services => + _$this._services ??= ListBuilder(); + set services(ListBuilder? services) => _$this._services = services; + TourBuilder? _tour; TourBuilder get tour => _$this._tour ??= TourBuilder(); set tour(TourBuilder? tour) => _$this._tour = tour; @@ -131,10 +190,15 @@ class TourDetailsBuilder implements Builder { final $v = _$v; if ($v != null) { _articles = $v.articles.toBuilder(); + _contactChannels = $v.contactChannels.toBuilder(); + _contactSources = $v.contactSources.toBuilder(); + _credits = $v.credits.toBuilder(); _customerContacts = $v.customerContacts.toBuilder(); _customers = $v.customers.toBuilder(); _deliveries = $v.deliveries.toBuilder(); + _deliveryServices = $v.deliveryServices.toBuilder(); _notes = $v.notes.toBuilder(); + _services = $v.services.toBuilder(); _tour = $v.tour.toBuilder(); _warehouses = $v.warehouses.toBuilder(); _$v = null; @@ -161,10 +225,15 @@ class TourDetailsBuilder implements Builder { _$result = _$v ?? _$TourDetails._( articles: articles.build(), + contactChannels: contactChannels.build(), + contactSources: contactSources.build(), + credits: credits.build(), customerContacts: customerContacts.build(), customers: customers.build(), deliveries: deliveries.build(), + deliveryServices: deliveryServices.build(), notes: notes.build(), + services: services.build(), tour: tour.build(), warehouses: warehouses.build(), ); @@ -173,14 +242,24 @@ class TourDetailsBuilder implements Builder { try { _$failedField = 'articles'; articles.build(); + _$failedField = 'contactChannels'; + contactChannels.build(); + _$failedField = 'contactSources'; + contactSources.build(); + _$failedField = 'credits'; + credits.build(); _$failedField = 'customerContacts'; customerContacts.build(); _$failedField = 'customers'; customers.build(); _$failedField = 'deliveries'; deliveries.build(); + _$failedField = 'deliveryServices'; + deliveryServices.build(); _$failedField = 'notes'; notes.build(); + _$failedField = 'services'; + services.build(); _$failedField = 'tour'; tour.build(); _$failedField = 'warehouses'; diff --git a/packages/holzleitner_api/lib/src/model/update_delivery_note_request.dart b/packages/holzleitner_api/lib/src/model/update_delivery_note_request.dart new file mode 100644 index 0000000..81684ee --- /dev/null +++ b/packages/holzleitner_api/lib/src/model/update_delivery_note_request.dart @@ -0,0 +1,129 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// + +// ignore_for_file: unused_element +import 'package:built_value/built_value.dart'; +import 'package:built_value/serializer.dart'; + +part 'update_delivery_note_request.g.dart'; + +/// Request für `PATCH /deliveries/{id}/notes/{note_id}`. Wie beim Create muss mindestens eines von `text` / `image_attachment` inhaltlich gefüllt sein — geprüft im Use Case. +/// +/// Properties: +/// * [imageAttachment] - Object-Storage-Key oder URL eines vorab hochgeladenen Bildes. +/// * [text] +@BuiltValue() +abstract class UpdateDeliveryNoteRequest implements Built { + /// Object-Storage-Key oder URL eines vorab hochgeladenen Bildes. + @BuiltValueField(wireName: r'imageAttachment') + String? get imageAttachment; + + @BuiltValueField(wireName: r'text') + String? get text; + + UpdateDeliveryNoteRequest._(); + + factory UpdateDeliveryNoteRequest([void updates(UpdateDeliveryNoteRequestBuilder b)]) = _$UpdateDeliveryNoteRequest; + + @BuiltValueHook(initializeBuilder: true) + static void _defaults(UpdateDeliveryNoteRequestBuilder b) => b; + + @BuiltValueSerializer(custom: true) + static Serializer get serializer => _$UpdateDeliveryNoteRequestSerializer(); +} + +class _$UpdateDeliveryNoteRequestSerializer implements PrimitiveSerializer { + @override + final Iterable types = const [UpdateDeliveryNoteRequest, _$UpdateDeliveryNoteRequest]; + + @override + final String wireName = r'UpdateDeliveryNoteRequest'; + + Iterable _serializeProperties( + Serializers serializers, + UpdateDeliveryNoteRequest object, { + FullType specifiedType = FullType.unspecified, + }) sync* { + if (object.imageAttachment != null) { + yield r'imageAttachment'; + yield serializers.serialize( + object.imageAttachment, + specifiedType: const FullType.nullable(String), + ); + } + if (object.text != null) { + yield r'text'; + yield serializers.serialize( + object.text, + specifiedType: const FullType.nullable(String), + ); + } + } + + @override + Object serialize( + Serializers serializers, + UpdateDeliveryNoteRequest object, { + FullType specifiedType = FullType.unspecified, + }) { + return _serializeProperties(serializers, object, specifiedType: specifiedType).toList(); + } + + void _deserializeProperties( + Serializers serializers, + Object serialized, { + FullType specifiedType = FullType.unspecified, + required List serializedList, + required UpdateDeliveryNoteRequestBuilder result, + required List unhandled, + }) { + for (var i = 0; i < serializedList.length; i += 2) { + final key = serializedList[i] as String; + final value = serializedList[i + 1]; + switch (key) { + case r'imageAttachment': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType.nullable(String), + ) as String?; + if (valueDes == null) continue; + result.imageAttachment = valueDes; + break; + case r'text': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType.nullable(String), + ) as String?; + if (valueDes == null) continue; + result.text = valueDes; + break; + default: + unhandled.add(key); + unhandled.add(value); + break; + } + } + } + + @override + UpdateDeliveryNoteRequest deserialize( + Serializers serializers, + Object serialized, { + FullType specifiedType = FullType.unspecified, + }) { + final result = UpdateDeliveryNoteRequestBuilder(); + final serializedList = (serialized as Iterable).toList(); + final unhandled = []; + _deserializeProperties( + serializers, + serialized, + specifiedType: specifiedType, + serializedList: serializedList, + unhandled: unhandled, + result: result, + ); + return result.build(); + } +} + diff --git a/packages/holzleitner_api/lib/src/model/update_delivery_note_request.g.dart b/packages/holzleitner_api/lib/src/model/update_delivery_note_request.g.dart new file mode 100644 index 0000000..08a7e65 --- /dev/null +++ b/packages/holzleitner_api/lib/src/model/update_delivery_note_request.g.dart @@ -0,0 +1,107 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'update_delivery_note_request.dart'; + +// ************************************************************************** +// BuiltValueGenerator +// ************************************************************************** + +class _$UpdateDeliveryNoteRequest extends UpdateDeliveryNoteRequest { + @override + final String? imageAttachment; + @override + final String? text; + + factory _$UpdateDeliveryNoteRequest( + [void Function(UpdateDeliveryNoteRequestBuilder)? updates]) => + (UpdateDeliveryNoteRequestBuilder()..update(updates))._build(); + + _$UpdateDeliveryNoteRequest._({this.imageAttachment, this.text}) : super._(); + @override + UpdateDeliveryNoteRequest rebuild( + void Function(UpdateDeliveryNoteRequestBuilder) updates) => + (toBuilder()..update(updates)).build(); + + @override + UpdateDeliveryNoteRequestBuilder toBuilder() => + UpdateDeliveryNoteRequestBuilder()..replace(this); + + @override + bool operator ==(Object other) { + if (identical(other, this)) return true; + return other is UpdateDeliveryNoteRequest && + imageAttachment == other.imageAttachment && + text == other.text; + } + + @override + int get hashCode { + var _$hash = 0; + _$hash = $jc(_$hash, imageAttachment.hashCode); + _$hash = $jc(_$hash, text.hashCode); + _$hash = $jf(_$hash); + return _$hash; + } + + @override + String toString() { + return (newBuiltValueToStringHelper(r'UpdateDeliveryNoteRequest') + ..add('imageAttachment', imageAttachment) + ..add('text', text)) + .toString(); + } +} + +class UpdateDeliveryNoteRequestBuilder + implements + Builder { + _$UpdateDeliveryNoteRequest? _$v; + + String? _imageAttachment; + String? get imageAttachment => _$this._imageAttachment; + set imageAttachment(String? imageAttachment) => + _$this._imageAttachment = imageAttachment; + + String? _text; + String? get text => _$this._text; + set text(String? text) => _$this._text = text; + + UpdateDeliveryNoteRequestBuilder() { + UpdateDeliveryNoteRequest._defaults(this); + } + + UpdateDeliveryNoteRequestBuilder get _$this { + final $v = _$v; + if ($v != null) { + _imageAttachment = $v.imageAttachment; + _text = $v.text; + _$v = null; + } + return this; + } + + @override + void replace(UpdateDeliveryNoteRequest other) { + _$v = other as _$UpdateDeliveryNoteRequest; + } + + @override + void update(void Function(UpdateDeliveryNoteRequestBuilder)? updates) { + if (updates != null) updates(this); + } + + @override + UpdateDeliveryNoteRequest build() => _build(); + + _$UpdateDeliveryNoteRequest _build() { + final _$result = _$v ?? + _$UpdateDeliveryNoteRequest._( + imageAttachment: imageAttachment, + text: text, + ); + replace(_$result); + return _$result; + } +} + +// ignore_for_file: deprecated_member_use_from_same_package,type=lint diff --git a/packages/holzleitner_api/lib/src/model/update_payment_method_request.dart b/packages/holzleitner_api/lib/src/model/update_payment_method_request.dart new file mode 100644 index 0000000..14a3253 --- /dev/null +++ b/packages/holzleitner_api/lib/src/model/update_payment_method_request.dart @@ -0,0 +1,130 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// + +// ignore_for_file: unused_element +import 'package:built_value/built_value.dart'; +import 'package:built_value/serializer.dart'; + +part 'update_payment_method_request.g.dart'; + +/// UpdatePaymentMethodRequest +/// +/// Properties: +/// * [active] - Wenn gesetzt: aktiv/inaktiv. Inaktive Methoden bleiben für historische Lieferungen referenzierbar, tauchen aber im Default-Listing nicht auf. +/// * [name] - Wenn gesetzt: neuer Anzeige-Name. +@BuiltValue() +abstract class UpdatePaymentMethodRequest implements Built { + /// Wenn gesetzt: aktiv/inaktiv. Inaktive Methoden bleiben für historische Lieferungen referenzierbar, tauchen aber im Default-Listing nicht auf. + @BuiltValueField(wireName: r'active') + bool? get active; + + /// Wenn gesetzt: neuer Anzeige-Name. + @BuiltValueField(wireName: r'name') + String? get name; + + UpdatePaymentMethodRequest._(); + + factory UpdatePaymentMethodRequest([void updates(UpdatePaymentMethodRequestBuilder b)]) = _$UpdatePaymentMethodRequest; + + @BuiltValueHook(initializeBuilder: true) + static void _defaults(UpdatePaymentMethodRequestBuilder b) => b; + + @BuiltValueSerializer(custom: true) + static Serializer get serializer => _$UpdatePaymentMethodRequestSerializer(); +} + +class _$UpdatePaymentMethodRequestSerializer implements PrimitiveSerializer { + @override + final Iterable types = const [UpdatePaymentMethodRequest, _$UpdatePaymentMethodRequest]; + + @override + final String wireName = r'UpdatePaymentMethodRequest'; + + Iterable _serializeProperties( + Serializers serializers, + UpdatePaymentMethodRequest object, { + FullType specifiedType = FullType.unspecified, + }) sync* { + if (object.active != null) { + yield r'active'; + yield serializers.serialize( + object.active, + specifiedType: const FullType.nullable(bool), + ); + } + if (object.name != null) { + yield r'name'; + yield serializers.serialize( + object.name, + specifiedType: const FullType.nullable(String), + ); + } + } + + @override + Object serialize( + Serializers serializers, + UpdatePaymentMethodRequest object, { + FullType specifiedType = FullType.unspecified, + }) { + return _serializeProperties(serializers, object, specifiedType: specifiedType).toList(); + } + + void _deserializeProperties( + Serializers serializers, + Object serialized, { + FullType specifiedType = FullType.unspecified, + required List serializedList, + required UpdatePaymentMethodRequestBuilder result, + required List unhandled, + }) { + for (var i = 0; i < serializedList.length; i += 2) { + final key = serializedList[i] as String; + final value = serializedList[i + 1]; + switch (key) { + case r'active': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType.nullable(bool), + ) as bool?; + if (valueDes == null) continue; + result.active = valueDes; + break; + case r'name': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType.nullable(String), + ) as String?; + if (valueDes == null) continue; + result.name = valueDes; + break; + default: + unhandled.add(key); + unhandled.add(value); + break; + } + } + } + + @override + UpdatePaymentMethodRequest deserialize( + Serializers serializers, + Object serialized, { + FullType specifiedType = FullType.unspecified, + }) { + final result = UpdatePaymentMethodRequestBuilder(); + final serializedList = (serialized as Iterable).toList(); + final unhandled = []; + _deserializeProperties( + serializers, + serialized, + specifiedType: specifiedType, + serializedList: serializedList, + unhandled: unhandled, + result: result, + ); + return result.build(); + } +} + diff --git a/packages/holzleitner_api/lib/src/model/update_payment_method_request.g.dart b/packages/holzleitner_api/lib/src/model/update_payment_method_request.g.dart new file mode 100644 index 0000000..d9d6d77 --- /dev/null +++ b/packages/holzleitner_api/lib/src/model/update_payment_method_request.g.dart @@ -0,0 +1,106 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'update_payment_method_request.dart'; + +// ************************************************************************** +// BuiltValueGenerator +// ************************************************************************** + +class _$UpdatePaymentMethodRequest extends UpdatePaymentMethodRequest { + @override + final bool? active; + @override + final String? name; + + factory _$UpdatePaymentMethodRequest( + [void Function(UpdatePaymentMethodRequestBuilder)? updates]) => + (UpdatePaymentMethodRequestBuilder()..update(updates))._build(); + + _$UpdatePaymentMethodRequest._({this.active, this.name}) : super._(); + @override + UpdatePaymentMethodRequest rebuild( + void Function(UpdatePaymentMethodRequestBuilder) updates) => + (toBuilder()..update(updates)).build(); + + @override + UpdatePaymentMethodRequestBuilder toBuilder() => + UpdatePaymentMethodRequestBuilder()..replace(this); + + @override + bool operator ==(Object other) { + if (identical(other, this)) return true; + return other is UpdatePaymentMethodRequest && + active == other.active && + name == other.name; + } + + @override + int get hashCode { + var _$hash = 0; + _$hash = $jc(_$hash, active.hashCode); + _$hash = $jc(_$hash, name.hashCode); + _$hash = $jf(_$hash); + return _$hash; + } + + @override + String toString() { + return (newBuiltValueToStringHelper(r'UpdatePaymentMethodRequest') + ..add('active', active) + ..add('name', name)) + .toString(); + } +} + +class UpdatePaymentMethodRequestBuilder + implements + Builder { + _$UpdatePaymentMethodRequest? _$v; + + bool? _active; + bool? get active => _$this._active; + set active(bool? active) => _$this._active = active; + + String? _name; + String? get name => _$this._name; + set name(String? name) => _$this._name = name; + + UpdatePaymentMethodRequestBuilder() { + UpdatePaymentMethodRequest._defaults(this); + } + + UpdatePaymentMethodRequestBuilder get _$this { + final $v = _$v; + if ($v != null) { + _active = $v.active; + _name = $v.name; + _$v = null; + } + return this; + } + + @override + void replace(UpdatePaymentMethodRequest other) { + _$v = other as _$UpdatePaymentMethodRequest; + } + + @override + void update(void Function(UpdatePaymentMethodRequestBuilder)? updates) { + if (updates != null) updates(this); + } + + @override + UpdatePaymentMethodRequest build() => _build(); + + _$UpdatePaymentMethodRequest _build() { + final _$result = _$v ?? + _$UpdatePaymentMethodRequest._( + active: active, + name: name, + ); + replace(_$result); + return _$result; + } +} + +// ignore_for_file: deprecated_member_use_from_same_package,type=lint diff --git a/packages/holzleitner_api/lib/src/model/update_service_request.dart b/packages/holzleitner_api/lib/src/model/update_service_request.dart new file mode 100644 index 0000000..2d69f69 --- /dev/null +++ b/packages/holzleitner_api/lib/src/model/update_service_request.dart @@ -0,0 +1,185 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// + +// ignore_for_file: unused_element +import 'package:built_value/built_value.dart'; +import 'package:built_value/serializer.dart'; + +part 'update_service_request.g.dart'; + +/// Teil-Update eines Service. `kind` ist bewusst **nicht** änderbar — ein Wechsel boolean↔numeric würde bestehende Pro-Lieferung-Werte ungültig machen (dann lieber deaktivieren + neu anlegen). +/// +/// Properties: +/// * [active] +/// * [maxValue] +/// * [minValue] +/// * [name] +/// * [sortOrder] +@BuiltValue() +abstract class UpdateServiceRequest implements Built { + @BuiltValueField(wireName: r'active') + bool? get active; + + @BuiltValueField(wireName: r'maxValue') + int? get maxValue; + + @BuiltValueField(wireName: r'minValue') + int? get minValue; + + @BuiltValueField(wireName: r'name') + String? get name; + + @BuiltValueField(wireName: r'sortOrder') + int? get sortOrder; + + UpdateServiceRequest._(); + + factory UpdateServiceRequest([void updates(UpdateServiceRequestBuilder b)]) = _$UpdateServiceRequest; + + @BuiltValueHook(initializeBuilder: true) + static void _defaults(UpdateServiceRequestBuilder b) => b; + + @BuiltValueSerializer(custom: true) + static Serializer get serializer => _$UpdateServiceRequestSerializer(); +} + +class _$UpdateServiceRequestSerializer implements PrimitiveSerializer { + @override + final Iterable types = const [UpdateServiceRequest, _$UpdateServiceRequest]; + + @override + final String wireName = r'UpdateServiceRequest'; + + Iterable _serializeProperties( + Serializers serializers, + UpdateServiceRequest object, { + FullType specifiedType = FullType.unspecified, + }) sync* { + if (object.active != null) { + yield r'active'; + yield serializers.serialize( + object.active, + specifiedType: const FullType.nullable(bool), + ); + } + if (object.maxValue != null) { + yield r'maxValue'; + yield serializers.serialize( + object.maxValue, + specifiedType: const FullType.nullable(int), + ); + } + if (object.minValue != null) { + yield r'minValue'; + yield serializers.serialize( + object.minValue, + specifiedType: const FullType.nullable(int), + ); + } + if (object.name != null) { + yield r'name'; + yield serializers.serialize( + object.name, + specifiedType: const FullType.nullable(String), + ); + } + if (object.sortOrder != null) { + yield r'sortOrder'; + yield serializers.serialize( + object.sortOrder, + specifiedType: const FullType.nullable(int), + ); + } + } + + @override + Object serialize( + Serializers serializers, + UpdateServiceRequest object, { + FullType specifiedType = FullType.unspecified, + }) { + return _serializeProperties(serializers, object, specifiedType: specifiedType).toList(); + } + + void _deserializeProperties( + Serializers serializers, + Object serialized, { + FullType specifiedType = FullType.unspecified, + required List serializedList, + required UpdateServiceRequestBuilder result, + required List unhandled, + }) { + for (var i = 0; i < serializedList.length; i += 2) { + final key = serializedList[i] as String; + final value = serializedList[i + 1]; + switch (key) { + case r'active': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType.nullable(bool), + ) as bool?; + if (valueDes == null) continue; + result.active = valueDes; + break; + case r'maxValue': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType.nullable(int), + ) as int?; + if (valueDes == null) continue; + result.maxValue = valueDes; + break; + case r'minValue': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType.nullable(int), + ) as int?; + if (valueDes == null) continue; + result.minValue = valueDes; + break; + case r'name': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType.nullable(String), + ) as String?; + if (valueDes == null) continue; + result.name = valueDes; + break; + case r'sortOrder': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType.nullable(int), + ) as int?; + if (valueDes == null) continue; + result.sortOrder = valueDes; + break; + default: + unhandled.add(key); + unhandled.add(value); + break; + } + } + } + + @override + UpdateServiceRequest deserialize( + Serializers serializers, + Object serialized, { + FullType specifiedType = FullType.unspecified, + }) { + final result = UpdateServiceRequestBuilder(); + final serializedList = (serialized as Iterable).toList(); + final unhandled = []; + _deserializeProperties( + serializers, + serialized, + specifiedType: specifiedType, + serializedList: serializedList, + unhandled: unhandled, + result: result, + ); + return result.build(); + } +} + diff --git a/packages/holzleitner_api/lib/src/model/update_service_request.g.dart b/packages/holzleitner_api/lib/src/model/update_service_request.g.dart new file mode 100644 index 0000000..4c30a1b --- /dev/null +++ b/packages/holzleitner_api/lib/src/model/update_service_request.g.dart @@ -0,0 +1,140 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'update_service_request.dart'; + +// ************************************************************************** +// BuiltValueGenerator +// ************************************************************************** + +class _$UpdateServiceRequest extends UpdateServiceRequest { + @override + final bool? active; + @override + final int? maxValue; + @override + final int? minValue; + @override + final String? name; + @override + final int? sortOrder; + + factory _$UpdateServiceRequest( + [void Function(UpdateServiceRequestBuilder)? updates]) => + (UpdateServiceRequestBuilder()..update(updates))._build(); + + _$UpdateServiceRequest._( + {this.active, this.maxValue, this.minValue, this.name, this.sortOrder}) + : super._(); + @override + UpdateServiceRequest rebuild( + void Function(UpdateServiceRequestBuilder) updates) => + (toBuilder()..update(updates)).build(); + + @override + UpdateServiceRequestBuilder toBuilder() => + UpdateServiceRequestBuilder()..replace(this); + + @override + bool operator ==(Object other) { + if (identical(other, this)) return true; + return other is UpdateServiceRequest && + active == other.active && + maxValue == other.maxValue && + minValue == other.minValue && + name == other.name && + sortOrder == other.sortOrder; + } + + @override + int get hashCode { + var _$hash = 0; + _$hash = $jc(_$hash, active.hashCode); + _$hash = $jc(_$hash, maxValue.hashCode); + _$hash = $jc(_$hash, minValue.hashCode); + _$hash = $jc(_$hash, name.hashCode); + _$hash = $jc(_$hash, sortOrder.hashCode); + _$hash = $jf(_$hash); + return _$hash; + } + + @override + String toString() { + return (newBuiltValueToStringHelper(r'UpdateServiceRequest') + ..add('active', active) + ..add('maxValue', maxValue) + ..add('minValue', minValue) + ..add('name', name) + ..add('sortOrder', sortOrder)) + .toString(); + } +} + +class UpdateServiceRequestBuilder + implements Builder { + _$UpdateServiceRequest? _$v; + + bool? _active; + bool? get active => _$this._active; + set active(bool? active) => _$this._active = active; + + int? _maxValue; + int? get maxValue => _$this._maxValue; + set maxValue(int? maxValue) => _$this._maxValue = maxValue; + + int? _minValue; + int? get minValue => _$this._minValue; + set minValue(int? minValue) => _$this._minValue = minValue; + + String? _name; + String? get name => _$this._name; + set name(String? name) => _$this._name = name; + + int? _sortOrder; + int? get sortOrder => _$this._sortOrder; + set sortOrder(int? sortOrder) => _$this._sortOrder = sortOrder; + + UpdateServiceRequestBuilder() { + UpdateServiceRequest._defaults(this); + } + + UpdateServiceRequestBuilder get _$this { + final $v = _$v; + if ($v != null) { + _active = $v.active; + _maxValue = $v.maxValue; + _minValue = $v.minValue; + _name = $v.name; + _sortOrder = $v.sortOrder; + _$v = null; + } + return this; + } + + @override + void replace(UpdateServiceRequest other) { + _$v = other as _$UpdateServiceRequest; + } + + @override + void update(void Function(UpdateServiceRequestBuilder)? updates) { + if (updates != null) updates(this); + } + + @override + UpdateServiceRequest build() => _build(); + + _$UpdateServiceRequest _build() { + final _$result = _$v ?? + _$UpdateServiceRequest._( + active: active, + maxValue: maxValue, + minValue: minValue, + name: name, + sortOrder: sortOrder, + ); + replace(_$result); + return _$result; + } +} + +// ignore_for_file: deprecated_member_use_from_same_package,type=lint diff --git a/packages/holzleitner_api/lib/src/serializers.dart b/packages/holzleitner_api/lib/src/serializers.dart index 4bb4392..cb6d09a 100644 --- a/packages/holzleitner_api/lib/src/serializers.dart +++ b/packages/holzleitner_api/lib/src/serializers.dart @@ -25,26 +25,53 @@ import 'package:holzleitner_api/src/model/cancel_delivery_request.dart'; import 'package:holzleitner_api/src/model/car.dart'; import 'package:holzleitner_api/src/model/car_response.dart'; import 'package:holzleitner_api/src/model/cars_list.dart'; +import 'package:holzleitner_api/src/model/complete_delivery_acknowledgements.dart'; +import 'package:holzleitner_api/src/model/contact_channel.dart'; +import 'package:holzleitner_api/src/model/contact_kind.dart'; +import 'package:holzleitner_api/src/model/contact_role.dart'; +import 'package:holzleitner_api/src/model/contact_source.dart'; import 'package:holzleitner_api/src/model/create_car_request.dart'; import 'package:holzleitner_api/src/model/create_delivery_note_request.dart'; +import 'package:holzleitner_api/src/model/create_payment_method_request.dart'; +import 'package:holzleitner_api/src/model/create_service_request.dart'; +import 'package:holzleitner_api/src/model/credit_action.dart'; import 'package:holzleitner_api/src/model/customer.dart'; import 'package:holzleitner_api/src/model/customer_contact.dart'; +import 'package:holzleitner_api/src/model/delivered_belegnummern_response.dart'; import 'package:holzleitner_api/src/model/delivery.dart'; +import 'package:holzleitner_api/src/model/delivery_credit.dart'; +import 'package:holzleitner_api/src/model/delivery_credit_event_request.dart'; +import 'package:holzleitner_api/src/model/delivery_credit_response.dart'; import 'package:holzleitner_api/src/model/delivery_item.dart'; import 'package:holzleitner_api/src/model/delivery_note.dart'; import 'package:holzleitner_api/src/model/delivery_note_response.dart'; import 'package:holzleitner_api/src/model/delivery_order_entry.dart'; import 'package:holzleitner_api/src/model/delivery_response.dart'; +import 'package:holzleitner_api/src/model/delivery_service_response.dart'; +import 'package:holzleitner_api/src/model/delivery_service_value.dart'; import 'package:holzleitner_api/src/model/delivery_state.dart'; import 'package:holzleitner_api/src/model/delivery_with_items.dart'; import 'package:holzleitner_api/src/model/hold_delivery_request.dart'; +import 'package:holzleitner_api/src/model/import_summary.dart'; +import 'package:holzleitner_api/src/model/mark_mail_sent_request.dart'; +import 'package:holzleitner_api/src/model/mark_mail_sent_response.dart'; +import 'package:holzleitner_api/src/model/payment_method.dart'; +import 'package:holzleitner_api/src/model/payment_method_response.dart'; +import 'package:holzleitner_api/src/model/payment_methods_list.dart'; import 'package:holzleitner_api/src/model/scan_event.dart'; import 'package:holzleitner_api/src/model/scan_result.dart'; import 'package:holzleitner_api/src/model/scan_result_status.dart'; import 'package:holzleitner_api/src/model/scan_state.dart'; import 'package:holzleitner_api/src/model/scan_status.dart'; +import 'package:holzleitner_api/src/model/service.dart'; +import 'package:holzleitner_api/src/model/service_kind.dart'; +import 'package:holzleitner_api/src/model/service_response.dart'; +import 'package:holzleitner_api/src/model/services_list.dart'; import 'package:holzleitner_api/src/model/set_delivery_order_request.dart'; import 'package:holzleitner_api/src/model/set_delivery_order_response.dart'; +import 'package:holzleitner_api/src/model/set_delivery_service_request.dart'; +import 'package:holzleitner_api/src/model/sync_contact_channel.dart'; +import 'package:holzleitner_api/src/model/sync_contact_source.dart'; import 'package:holzleitner_api/src/model/sync_delivery.dart'; import 'package:holzleitner_api/src/model/sync_delivery_item.dart'; import 'package:holzleitner_api/src/model/sync_tour_request.dart'; @@ -54,6 +81,9 @@ import 'package:holzleitner_api/src/model/tour_details.dart'; import 'package:holzleitner_api/src/model/tour_summary.dart'; import 'package:holzleitner_api/src/model/tour_summary_list.dart'; import 'package:holzleitner_api/src/model/update_car_request.dart'; +import 'package:holzleitner_api/src/model/update_delivery_note_request.dart'; +import 'package:holzleitner_api/src/model/update_payment_method_request.dart'; +import 'package:holzleitner_api/src/model/update_service_request.dart'; import 'package:holzleitner_api/src/model/warehouse.dart'; part 'serializers.g.dart'; @@ -70,26 +100,53 @@ part 'serializers.g.dart'; Car, CarResponse, CarsList, + CompleteDeliveryAcknowledgements, + ContactChannel, + ContactKind, + ContactRole, + ContactSource, CreateCarRequest, CreateDeliveryNoteRequest, + CreatePaymentMethodRequest, + CreateServiceRequest, + CreditAction, Customer, CustomerContact, + DeliveredBelegnummernResponse, Delivery,$Delivery, + DeliveryCredit, + DeliveryCreditEventRequest, + DeliveryCreditResponse, DeliveryItem, DeliveryNote, DeliveryNoteResponse, DeliveryOrderEntry, DeliveryResponse, + DeliveryServiceResponse, + DeliveryServiceValue, DeliveryState, DeliveryWithItems, HoldDeliveryRequest, + ImportSummary, + MarkMailSentRequest, + MarkMailSentResponse, + PaymentMethod, + PaymentMethodResponse, + PaymentMethodsList, ScanEvent, ScanResult, ScanResultStatus, ScanState, ScanStatus, + Service, + ServiceKind, + ServiceResponse, + ServicesList, SetDeliveryOrderRequest, SetDeliveryOrderResponse, + SetDeliveryServiceRequest, + SyncContactChannel, + SyncContactSource, SyncDelivery, SyncDeliveryItem, SyncTourRequest, @@ -99,6 +156,9 @@ part 'serializers.g.dart'; TourSummary, TourSummaryList, UpdateCarRequest, + UpdateDeliveryNoteRequest, + UpdatePaymentMethodRequest, + UpdateServiceRequest, Warehouse, ]) Serializers serializers = (_$serializers.toBuilder() diff --git a/packages/holzleitner_api/lib/src/serializers.g.dart b/packages/holzleitner_api/lib/src/serializers.g.dart index f00e4c6..c6ecb24 100644 --- a/packages/holzleitner_api/lib/src/serializers.g.dart +++ b/packages/holzleitner_api/lib/src/serializers.g.dart @@ -19,25 +19,52 @@ Serializers _$serializers = (Serializers().toBuilder() ..add(Car.serializer) ..add(CarResponse.serializer) ..add(CarsList.serializer) + ..add(CompleteDeliveryAcknowledgements.serializer) + ..add(ContactChannel.serializer) + ..add(ContactKind.serializer) + ..add(ContactRole.serializer) + ..add(ContactSource.serializer) ..add(CreateCarRequest.serializer) ..add(CreateDeliveryNoteRequest.serializer) + ..add(CreatePaymentMethodRequest.serializer) + ..add(CreateServiceRequest.serializer) + ..add(CreditAction.serializer) ..add(Customer.serializer) ..add(CustomerContact.serializer) + ..add(DeliveredBelegnummernResponse.serializer) + ..add(DeliveryCredit.serializer) + ..add(DeliveryCreditEventRequest.serializer) + ..add(DeliveryCreditResponse.serializer) ..add(DeliveryItem.serializer) ..add(DeliveryNote.serializer) ..add(DeliveryNoteResponse.serializer) ..add(DeliveryOrderEntry.serializer) ..add(DeliveryResponse.serializer) + ..add(DeliveryServiceResponse.serializer) + ..add(DeliveryServiceValue.serializer) ..add(DeliveryState.serializer) ..add(DeliveryWithItems.serializer) ..add(HoldDeliveryRequest.serializer) + ..add(ImportSummary.serializer) + ..add(MarkMailSentRequest.serializer) + ..add(MarkMailSentResponse.serializer) + ..add(PaymentMethod.serializer) + ..add(PaymentMethodResponse.serializer) + ..add(PaymentMethodsList.serializer) ..add(ScanEvent.serializer) ..add(ScanResult.serializer) ..add(ScanResultStatus.serializer) ..add(ScanState.serializer) ..add(ScanStatus.serializer) + ..add(Service.serializer) + ..add(ServiceKind.serializer) + ..add(ServiceResponse.serializer) + ..add(ServicesList.serializer) ..add(SetDeliveryOrderRequest.serializer) ..add(SetDeliveryOrderResponse.serializer) + ..add(SetDeliveryServiceRequest.serializer) + ..add(SyncContactChannel.serializer) + ..add(SyncContactSource.serializer) ..add(SyncDelivery.serializer) ..add(SyncDeliveryItem.serializer) ..add(SyncTourRequest.serializer) @@ -47,10 +74,22 @@ Serializers _$serializers = (Serializers().toBuilder() ..add(TourSummary.serializer) ..add(TourSummaryList.serializer) ..add(UpdateCarRequest.serializer) + ..add(UpdateDeliveryNoteRequest.serializer) + ..add(UpdatePaymentMethodRequest.serializer) + ..add(UpdateServiceRequest.serializer) ..add(Warehouse.serializer) ..addBuilderFactory( const FullType(BuiltList, const [const FullType(Article)]), () => ListBuilder
()) + ..addBuilderFactory( + const FullType(BuiltList, const [const FullType(ContactChannel)]), + () => ListBuilder()) + ..addBuilderFactory( + const FullType(BuiltList, const [const FullType(ContactSource)]), + () => ListBuilder()) + ..addBuilderFactory( + const FullType(BuiltList, const [const FullType(DeliveryCredit)]), + () => ListBuilder()) ..addBuilderFactory( const FullType(BuiltList, const [const FullType(CustomerContact)]), () => ListBuilder()) @@ -60,9 +99,16 @@ Serializers _$serializers = (Serializers().toBuilder() ..addBuilderFactory( const FullType(BuiltList, const [const FullType(DeliveryWithItems)]), () => ListBuilder()) + ..addBuilderFactory( + const FullType( + BuiltList, const [const FullType(DeliveryServiceValue)]), + () => ListBuilder()) ..addBuilderFactory( const FullType(BuiltList, const [const FullType(DeliveryNote)]), () => ListBuilder()) + ..addBuilderFactory( + const FullType(BuiltList, const [const FullType(Service)]), + () => ListBuilder()) ..addBuilderFactory( const FullType(BuiltList, const [const FullType(Warehouse)]), () => ListBuilder()) @@ -78,12 +124,21 @@ Serializers _$serializers = (Serializers().toBuilder() ..addBuilderFactory( const FullType(BuiltList, const [const FullType(DeliveryOrderEntry)]), () => ListBuilder()) + ..addBuilderFactory( + const FullType(BuiltList, const [const FullType(PaymentMethod)]), + () => ListBuilder()) ..addBuilderFactory( const FullType(BuiltList, const [const FullType(ScanEvent)]), () => ListBuilder()) ..addBuilderFactory( const FullType(BuiltList, const [const FullType(ScanResult)]), () => ListBuilder()) + ..addBuilderFactory( + const FullType(BuiltList, const [const FullType(Service)]), + () => ListBuilder()) + ..addBuilderFactory( + const FullType(BuiltList, const [const FullType(String)]), + () => ListBuilder()) ..addBuilderFactory( const FullType(BuiltList, const [const FullType(String)]), () => ListBuilder()) @@ -91,11 +146,29 @@ Serializers _$serializers = (Serializers().toBuilder() const FullType(BuiltList, const [const FullType(String)]), () => ListBuilder()) ..addBuilderFactory( - const FullType(BuiltList, const [const FullType(SyncDelivery)]), - () => ListBuilder()) + const FullType(BuiltList, const [const FullType(String)]), + () => ListBuilder()) + ..addBuilderFactory( + const FullType(BuiltList, const [const FullType(String)]), + () => ListBuilder()) + ..addBuilderFactory( + const FullType(BuiltList, const [const FullType(String)]), + () => ListBuilder()) + ..addBuilderFactory( + const FullType(BuiltList, const [const FullType(String)]), + () => ListBuilder()) + ..addBuilderFactory( + const FullType(BuiltList, const [const FullType(SyncContactChannel)]), + () => ListBuilder()) + ..addBuilderFactory( + const FullType(BuiltList, const [const FullType(SyncContactSource)]), + () => ListBuilder()) ..addBuilderFactory( const FullType(BuiltList, const [const FullType(SyncDeliveryItem)]), () => ListBuilder()) + ..addBuilderFactory( + const FullType(BuiltList, const [const FullType(SyncDelivery)]), + () => ListBuilder()) ..addBuilderFactory( const FullType(BuiltList, const [const FullType(TourSummary)]), () => ListBuilder())) diff --git a/packages/holzleitner_api/test/admin_api_test.dart b/packages/holzleitner_api/test/admin_api_test.dart new file mode 100644 index 0000000..3d49231 --- /dev/null +++ b/packages/holzleitner_api/test/admin_api_test.dart @@ -0,0 +1,25 @@ +import 'package:test/test.dart'; +import 'package:holzleitner_api/holzleitner_api.dart'; + + +/// tests for AdminApi +void main() { + final instance = HolzleitnerApi().getAdminApi(); + + group(AdminApi, () { + // Stößt den ERP-Import für ein Datum an und liefert die Zusammenfassung. + // + //Future importErp({ String date }) async + test('test importErp', () async { + // TODO + }); + + // Stößt das ERP-Rückschreiben eines bereits lokal abgeschlossenen Lieferabschlusses erneut an (idempotenter Retry, falls der automatische Push beim Abschluss fehlschlug). + // + //Future pushCompletion(String deliveryId) async + test('test pushCompletion', () async { + // TODO + }); + + }); +} diff --git a/packages/holzleitner_api/test/attachments_api_test.dart b/packages/holzleitner_api/test/attachments_api_test.dart new file mode 100644 index 0000000..709309c --- /dev/null +++ b/packages/holzleitner_api/test/attachments_api_test.dart @@ -0,0 +1,18 @@ +import 'package:test/test.dart'; +import 'package:holzleitner_api/holzleitner_api.dart'; + + +/// tests for AttachmentsApi +void main() { + final instance = HolzleitnerApi().getAttachmentsApi(); + + group(AttachmentsApi, () { + // Liefert ein gerendertes Vorschaubild des Attachments (Bytes), geladen aus DOCUframe. Auflösung/Format über Query-Parameter steuerbar (`?w=&h=&q=&ext=&page=`). + // + //Future getAttachment(String id, { int w, int h, int q, String ext, String page }) async + test('test getAttachment', () async { + // TODO + }); + + }); +} diff --git a/packages/holzleitner_api/test/complete_delivery_acknowledgements_test.dart b/packages/holzleitner_api/test/complete_delivery_acknowledgements_test.dart new file mode 100644 index 0000000..bca95d8 --- /dev/null +++ b/packages/holzleitner_api/test/complete_delivery_acknowledgements_test.dart @@ -0,0 +1,35 @@ +import 'package:test/test.dart'; +import 'package:holzleitner_api/holzleitner_api.dart'; + +// tests for CompleteDeliveryAcknowledgements +void main() { + final instance = CompleteDeliveryAcknowledgementsBuilder(); + // TODO add properties to the builder and call build() + + group(CompleteDeliveryAcknowledgements, () { + // Notiz-IDs, die zum Abschlusszeitpunkt sichtbar waren und mit-bestätigt wurden (Audit-Robustheit). + // BuiltList acknowledgedNoteIds + test('to test the property `acknowledgedNoteIds`', () async { + // TODO + }); + + // Fahrzeug des Akteurs (Audit-Spur). Muss zum Account gehören. + // String authorCarId + test('to test the property `authorCarId`', () async { + // TODO + }); + + // „Anmerkungen zur Lieferung zur Kenntnis genommen.\" — Pflicht nur, wenn Notizen existieren (das prüft der Server). + // bool notesAcknowledged + test('to test the property `notesAcknowledged`', () async { + // TODO + }); + + // „Ware im ordnungsgemäßen Zustand erhalten / Aufbau korrekt.\" — Pflicht. + // bool receiptConfirmed + test('to test the property `receiptConfirmed`', () async { + // TODO + }); + + }); +} diff --git a/packages/holzleitner_api/test/contact_channel_test.dart b/packages/holzleitner_api/test/contact_channel_test.dart new file mode 100644 index 0000000..c779c5a --- /dev/null +++ b/packages/holzleitner_api/test/contact_channel_test.dart @@ -0,0 +1,36 @@ +import 'package:test/test.dart'; +import 'package:holzleitner_api/holzleitner_api.dart'; + +// tests for ContactChannel +void main() { + final instance = ContactChannelBuilder(); + // TODO add properties to the builder and call build() + + group(ContactChannel, () { + // String id + test('to test the property `id`', () async { + // TODO + }); + + // ContactKind kind + test('to test the property `kind`', () async { + // TODO + }); + + // int position + test('to test the property `position`', () async { + // TODO + }); + + // String sourceId + test('to test the property `sourceId`', () async { + // TODO + }); + + // String value + test('to test the property `value`', () async { + // TODO + }); + + }); +} diff --git a/packages/holzleitner_api/test/contact_kind_test.dart b/packages/holzleitner_api/test/contact_kind_test.dart new file mode 100644 index 0000000..6650d7d --- /dev/null +++ b/packages/holzleitner_api/test/contact_kind_test.dart @@ -0,0 +1,9 @@ +import 'package:test/test.dart'; +import 'package:holzleitner_api/holzleitner_api.dart'; + +// tests for ContactKind +void main() { + + group(ContactKind, () { + }); +} diff --git a/packages/holzleitner_api/test/contact_role_test.dart b/packages/holzleitner_api/test/contact_role_test.dart new file mode 100644 index 0000000..3a44e22 --- /dev/null +++ b/packages/holzleitner_api/test/contact_role_test.dart @@ -0,0 +1,9 @@ +import 'package:test/test.dart'; +import 'package:holzleitner_api/holzleitner_api.dart'; + +// tests for ContactRole +void main() { + + group(ContactRole, () { + }); +} diff --git a/packages/holzleitner_api/test/contact_source_test.dart b/packages/holzleitner_api/test/contact_source_test.dart new file mode 100644 index 0000000..6a7e8f2 --- /dev/null +++ b/packages/holzleitner_api/test/contact_source_test.dart @@ -0,0 +1,61 @@ +import 'package:test/test.dart'; +import 'package:holzleitner_api/holzleitner_api.dart'; + +// tests for ContactSource +void main() { + final instance = ContactSourceBuilder(); + // TODO add properties to the builder and call build() + + group(ContactSource, () { + // String abteilung + test('to test the property `abteilung`', () async { + // TODO + }); + + // String anrede + test('to test the property `anrede`', () async { + // TODO + }); + + // String deliveryId + test('to test the property `deliveryId`', () async { + // TODO + }); + + // String funktion + test('to test the property `funktion`', () async { + // TODO + }); + + // String id + test('to test the property `id`', () async { + // TODO + }); + + // String name1 + test('to test the property `name1`', () async { + // TODO + }); + + // String name2 + test('to test the property `name2`', () async { + // TODO + }); + + // String name3 + test('to test the property `name3`', () async { + // TODO + }); + + // ContactRole role + test('to test the property `role`', () async { + // TODO + }); + + // String titel + test('to test the property `titel`', () async { + // TODO + }); + + }); +} diff --git a/packages/holzleitner_api/test/create_payment_method_request_test.dart b/packages/holzleitner_api/test/create_payment_method_request_test.dart new file mode 100644 index 0000000..48c884d --- /dev/null +++ b/packages/holzleitner_api/test/create_payment_method_request_test.dart @@ -0,0 +1,23 @@ +import 'package:test/test.dart'; +import 'package:holzleitner_api/holzleitner_api.dart'; + +// tests for CreatePaymentMethodRequest +void main() { + final instance = CreatePaymentMethodRequestBuilder(); + // TODO add properties to the builder and call build() + + group(CreatePaymentMethodRequest, () { + // Eindeutiger Programm-Identifier (z. B. `\"paypal\"`, `\"klarna\"`). + // String code + test('to test the property `code`', () async { + // TODO + }); + + // Anzeige-Name in der UI. + // String name + test('to test the property `name`', () async { + // TODO + }); + + }); +} diff --git a/packages/holzleitner_api/test/create_service_request_test.dart b/packages/holzleitner_api/test/create_service_request_test.dart new file mode 100644 index 0000000..d80b4d1 --- /dev/null +++ b/packages/holzleitner_api/test/create_service_request_test.dart @@ -0,0 +1,43 @@ +import 'package:test/test.dart'; +import 'package:holzleitner_api/holzleitner_api.dart'; + +// tests for CreateServiceRequest +void main() { + final instance = CreateServiceRequestBuilder(); + // TODO add properties to the builder and call build() + + group(CreateServiceRequest, () { + // Eindeutiger Programm-Identifier (z. B. `\"podium_setup\"`). + // String key + test('to test the property `key`', () async { + // TODO + }); + + // ServiceKind kind + test('to test the property `kind`', () async { + // TODO + }); + + // int maxValue + test('to test the property `maxValue`', () async { + // TODO + }); + + // Nur bei `Numeric` sinnvoll. + // int minValue + test('to test the property `minValue`', () async { + // TODO + }); + + // String name + test('to test the property `name`', () async { + // TODO + }); + + // int sortOrder + test('to test the property `sortOrder`', () async { + // TODO + }); + + }); +} diff --git a/packages/holzleitner_api/test/credit_action_test.dart b/packages/holzleitner_api/test/credit_action_test.dart new file mode 100644 index 0000000..ba659b5 --- /dev/null +++ b/packages/holzleitner_api/test/credit_action_test.dart @@ -0,0 +1,9 @@ +import 'package:test/test.dart'; +import 'package:holzleitner_api/holzleitner_api.dart'; + +// tests for CreditAction +void main() { + + group(CreditAction, () { + }); +} diff --git a/packages/holzleitner_api/test/delivered_belegnummern_response_test.dart b/packages/holzleitner_api/test/delivered_belegnummern_response_test.dart new file mode 100644 index 0000000..4b90843 --- /dev/null +++ b/packages/holzleitner_api/test/delivered_belegnummern_response_test.dart @@ -0,0 +1,29 @@ +import 'package:test/test.dart'; +import 'package:holzleitner_api/holzleitner_api.dart'; + +// tests for DeliveredBelegnummernResponse +void main() { + final instance = DeliveredBelegnummernResponseBuilder(); + // TODO add properties to the builder and call build() + + group(DeliveredBelegnummernResponse, () { + // Belegnummern aller **ausgelieferten** (abgeschlossenen) Lieferungen, deren Liefermail noch **nicht versendet** wurde, aufsteigend nach Abschluss-Zeitpunkt. + // BuiltList belegnummern + test('to test the property `belegnummern`', () async { + // TODO + }); + + // Anzahl der offenen (noch nicht versendeten) Belege. + // int count + test('to test the property `count`', () async { + // TODO + }); + + // Tag, nach dem gefiltert wurde (ISO `YYYY-MM-DD`), oder `\"all\"` wenn kein `day` angegeben war. + // String day + test('to test the property `day`', () async { + // TODO + }); + + }); +} diff --git a/packages/holzleitner_api/test/delivery_credit_event_request_test.dart b/packages/holzleitner_api/test/delivery_credit_event_request_test.dart new file mode 100644 index 0000000..3b8225d --- /dev/null +++ b/packages/holzleitner_api/test/delivery_credit_event_request_test.dart @@ -0,0 +1,40 @@ +import 'package:test/test.dart'; +import 'package:holzleitner_api/holzleitner_api.dart'; + +// tests for DeliveryCreditEventRequest +void main() { + final instance = DeliveryCreditEventRequestBuilder(); + // TODO add properties to the builder and call build() + + group(DeliveryCreditEventRequest, () { + // CreditAction action + test('to test the property `action`', () async { + // TODO + }); + + // Bei `Set` Pflicht: Betrag in Cent (> 0, ≤ 15000, Vielfaches von 1000). + // int amountCents + test('to test the property `amountCents`', () async { + // TODO + }); + + // Fahrzeug des Akteurs (Audit-Spur). Muss zum Account gehören. + // String authorCarId + test('to test the property `authorCarId`', () async { + // TODO + }); + + // Idempotenz-Schlüssel — pro erzeugtem Ereignis genau einmal vergeben. Ein Retry mit derselben Id wendet nichts erneut an. + // String clientEventId + test('to test the property `clientEventId`', () async { + // TODO + }); + + // Bei `Set` Pflicht: Begründung. + // String reason + test('to test the property `reason`', () async { + // TODO + }); + + }); +} diff --git a/packages/holzleitner_api/test/delivery_credit_response_test.dart b/packages/holzleitner_api/test/delivery_credit_response_test.dart new file mode 100644 index 0000000..7bf3f22 --- /dev/null +++ b/packages/holzleitner_api/test/delivery_credit_response_test.dart @@ -0,0 +1,17 @@ +import 'package:test/test.dart'; +import 'package:holzleitner_api/holzleitner_api.dart'; + +// tests for DeliveryCreditResponse +void main() { + final instance = DeliveryCreditResponseBuilder(); + // TODO add properties to the builder and call build() + + group(DeliveryCreditResponse, () { + // Aktueller Stand nach dem Ereignis — `None`, wenn (zuletzt) entfernt. + // DeliveryCredit credit + test('to test the property `credit`', () async { + // TODO + }); + + }); +} diff --git a/packages/holzleitner_api/test/delivery_credit_test.dart b/packages/holzleitner_api/test/delivery_credit_test.dart new file mode 100644 index 0000000..b02421d --- /dev/null +++ b/packages/holzleitner_api/test/delivery_credit_test.dart @@ -0,0 +1,27 @@ +import 'package:test/test.dart'; +import 'package:holzleitner_api/holzleitner_api.dart'; + +// tests for DeliveryCredit +void main() { + final instance = DeliveryCreditBuilder(); + // TODO add properties to the builder and call build() + + group(DeliveryCredit, () { + // Gutschrift-Betrag in Cent (> 0, ≤ 15000). + // int amountCents + test('to test the property `amountCents`', () async { + // TODO + }); + + // String deliveryId + test('to test the property `deliveryId`', () async { + // TODO + }); + + // String reason + test('to test the property `reason`', () async { + // TODO + }); + + }); +} diff --git a/packages/holzleitner_api/test/delivery_service_response_test.dart b/packages/holzleitner_api/test/delivery_service_response_test.dart new file mode 100644 index 0000000..26f030c --- /dev/null +++ b/packages/holzleitner_api/test/delivery_service_response_test.dart @@ -0,0 +1,16 @@ +import 'package:test/test.dart'; +import 'package:holzleitner_api/holzleitner_api.dart'; + +// tests for DeliveryServiceResponse +void main() { + final instance = DeliveryServiceResponseBuilder(); + // TODO add properties to the builder and call build() + + group(DeliveryServiceResponse, () { + // DeliveryServiceValue value + test('to test the property `value`', () async { + // TODO + }); + + }); +} diff --git a/packages/holzleitner_api/test/delivery_service_value_test.dart b/packages/holzleitner_api/test/delivery_service_value_test.dart new file mode 100644 index 0000000..e42ecb0 --- /dev/null +++ b/packages/holzleitner_api/test/delivery_service_value_test.dart @@ -0,0 +1,31 @@ +import 'package:test/test.dart'; +import 'package:holzleitner_api/holzleitner_api.dart'; + +// tests for DeliveryServiceValue +void main() { + final instance = DeliveryServiceValueBuilder(); + // TODO add properties to the builder and call build() + + group(DeliveryServiceValue, () { + // bool boolValue + test('to test the property `boolValue`', () async { + // TODO + }); + + // String deliveryId + test('to test the property `deliveryId`', () async { + // TODO + }); + + // int numericValue + test('to test the property `numericValue`', () async { + // TODO + }); + + // String serviceId + test('to test the property `serviceId`', () async { + // TODO + }); + + }); +} diff --git a/packages/holzleitner_api/test/import_summary_test.dart b/packages/holzleitner_api/test/import_summary_test.dart new file mode 100644 index 0000000..a974c1e --- /dev/null +++ b/packages/holzleitner_api/test/import_summary_test.dart @@ -0,0 +1,37 @@ +import 'package:test/test.dart'; +import 'package:holzleitner_api/holzleitner_api.dart'; + +// tests for ImportSummary +void main() { + final instance = ImportSummaryBuilder(); + // TODO add properties to the builder and call build() + + group(ImportSummary, () { + // Date date + test('to test the property `date`', () async { + // TODO + }); + + // Fehlertexte je fehlgeschlagener Fahrer-Tour (z. B. unbekannter Fahrer → FK auf `accounts`, oder Validierungsfehler). + // BuiltList errors + test('to test the property `errors`', () async { + // TODO + }); + + // int toursFailed + test('to test the property `toursFailed`', () async { + // TODO + }); + + // int toursOk + test('to test the property `toursOk`', () async { + // TODO + }); + + // int toursTotal + test('to test the property `toursTotal`', () async { + // TODO + }); + + }); +} diff --git a/packages/holzleitner_api/test/mark_mail_sent_request_test.dart b/packages/holzleitner_api/test/mark_mail_sent_request_test.dart new file mode 100644 index 0000000..5c229ba --- /dev/null +++ b/packages/holzleitner_api/test/mark_mail_sent_request_test.dart @@ -0,0 +1,17 @@ +import 'package:test/test.dart'; +import 'package:holzleitner_api/holzleitner_api.dart'; + +// tests for MarkMailSentRequest +void main() { + final instance = MarkMailSentRequestBuilder(); + // TODO add properties to the builder and call build() + + group(MarkMailSentRequest, () { + // Belegnummern, deren Liefermail erfolgreich versendet wurde und die als versendet markiert werden sollen. + // BuiltList belegnummern + test('to test the property `belegnummern`', () async { + // TODO + }); + + }); +} diff --git a/packages/holzleitner_api/test/mark_mail_sent_response_test.dart b/packages/holzleitner_api/test/mark_mail_sent_response_test.dart new file mode 100644 index 0000000..acafd91 --- /dev/null +++ b/packages/holzleitner_api/test/mark_mail_sent_response_test.dart @@ -0,0 +1,17 @@ +import 'package:test/test.dart'; +import 'package:holzleitner_api/holzleitner_api.dart'; + +// tests for MarkMailSentResponse +void main() { + final instance = MarkMailSentResponseBuilder(); + // TODO add properties to the builder and call build() + + group(MarkMailSentResponse, () { + // Anzahl frisch markierter (vorher offener) Belege. Bereits markierte zählen nicht mit (idempotent). + // int marked + test('to test the property `marked`', () async { + // TODO + }); + + }); +} diff --git a/packages/holzleitner_api/test/payment_method_response_test.dart b/packages/holzleitner_api/test/payment_method_response_test.dart new file mode 100644 index 0000000..206d237 --- /dev/null +++ b/packages/holzleitner_api/test/payment_method_response_test.dart @@ -0,0 +1,16 @@ +import 'package:test/test.dart'; +import 'package:holzleitner_api/holzleitner_api.dart'; + +// tests for PaymentMethodResponse +void main() { + final instance = PaymentMethodResponseBuilder(); + // TODO add properties to the builder and call build() + + group(PaymentMethodResponse, () { + // PaymentMethod method + test('to test the property `method`', () async { + // TODO + }); + + }); +} diff --git a/packages/holzleitner_api/test/payment_method_test.dart b/packages/holzleitner_api/test/payment_method_test.dart new file mode 100644 index 0000000..2c76a12 --- /dev/null +++ b/packages/holzleitner_api/test/payment_method_test.dart @@ -0,0 +1,38 @@ +import 'package:test/test.dart'; +import 'package:holzleitner_api/holzleitner_api.dart'; + +// tests for PaymentMethod +void main() { + final instance = PaymentMethodBuilder(); + // TODO add properties to the builder and call build() + + group(PaymentMethod, () { + // bool active + test('to test the property `active`', () async { + // TODO + }); + + // Stabiler Programm-Identifier — z. B. `\"cash\"`, `\"ec_card\"`. Eindeutig pro Eintrag. Wird vom Aufrufer beim Anlegen gesetzt. + // String code + test('to test the property `code`', () async { + // TODO + }); + + // DateTime createdAt + test('to test the property `createdAt`', () async { + // TODO + }); + + // String id + test('to test the property `id`', () async { + // TODO + }); + + // Display-Name in der UI — frei via PATCH änderbar. + // String name + test('to test the property `name`', () async { + // TODO + }); + + }); +} diff --git a/packages/holzleitner_api/test/payment_methods_api_test.dart b/packages/holzleitner_api/test/payment_methods_api_test.dart new file mode 100644 index 0000000..e58b9b8 --- /dev/null +++ b/packages/holzleitner_api/test/payment_methods_api_test.dart @@ -0,0 +1,39 @@ +import 'package:test/test.dart'; +import 'package:holzleitner_api/holzleitner_api.dart'; + + +/// tests for PaymentMethodsApi +void main() { + final instance = HolzleitnerApi().getPaymentMethodsApi(); + + group(PaymentMethodsApi, () { + // Legt eine neue Zahlungsmethode an. + // + //Future createPaymentMethod(CreatePaymentMethodRequest createPaymentMethodRequest) async + test('test createPaymentMethod', () async { + // TODO + }); + + // Hartes Löschen. `409 Conflict`, wenn die Methode von einer Lieferung referenziert wird — der Admin soll dann den `active = false`-Pfad nutzen. + // + //Future deletePaymentMethod(String id) async + test('test deletePaymentMethod', () async { + // TODO + }); + + // Listet die Zahlungsmethoden. + // + //Future listPaymentMethods({ bool includeInactive }) async + test('test listPaymentMethods', () async { + // TODO + }); + + // Patcht Anzeige-Name und/oder Aktiv-Flag. + // + //Future updatePaymentMethod(String id, UpdatePaymentMethodRequest updatePaymentMethodRequest) async + test('test updatePaymentMethod', () async { + // TODO + }); + + }); +} diff --git a/packages/holzleitner_api/test/payment_methods_list_test.dart b/packages/holzleitner_api/test/payment_methods_list_test.dart new file mode 100644 index 0000000..a9faf38 --- /dev/null +++ b/packages/holzleitner_api/test/payment_methods_list_test.dart @@ -0,0 +1,16 @@ +import 'package:test/test.dart'; +import 'package:holzleitner_api/holzleitner_api.dart'; + +// tests for PaymentMethodsList +void main() { + final instance = PaymentMethodsListBuilder(); + // TODO add properties to the builder and call build() + + group(PaymentMethodsList, () { + // BuiltList methods + test('to test the property `methods`', () async { + // TODO + }); + + }); +} diff --git a/packages/holzleitner_api/test/service_kind_test.dart b/packages/holzleitner_api/test/service_kind_test.dart new file mode 100644 index 0000000..8208682 --- /dev/null +++ b/packages/holzleitner_api/test/service_kind_test.dart @@ -0,0 +1,9 @@ +import 'package:test/test.dart'; +import 'package:holzleitner_api/holzleitner_api.dart'; + +// tests for ServiceKind +void main() { + + group(ServiceKind, () { + }); +} diff --git a/packages/holzleitner_api/test/service_response_test.dart b/packages/holzleitner_api/test/service_response_test.dart new file mode 100644 index 0000000..fa18d36 --- /dev/null +++ b/packages/holzleitner_api/test/service_response_test.dart @@ -0,0 +1,16 @@ +import 'package:test/test.dart'; +import 'package:holzleitner_api/holzleitner_api.dart'; + +// tests for ServiceResponse +void main() { + final instance = ServiceResponseBuilder(); + // TODO add properties to the builder and call build() + + group(ServiceResponse, () { + // Service service + test('to test the property `service`', () async { + // TODO + }); + + }); +} diff --git a/packages/holzleitner_api/test/service_test.dart b/packages/holzleitner_api/test/service_test.dart new file mode 100644 index 0000000..a4ef02d --- /dev/null +++ b/packages/holzleitner_api/test/service_test.dart @@ -0,0 +1,51 @@ +import 'package:test/test.dart'; +import 'package:holzleitner_api/holzleitner_api.dart'; + +// tests for Service +void main() { + final instance = ServiceBuilder(); + // TODO add properties to the builder and call build() + + group(Service, () { + // bool active + test('to test the property `active`', () async { + // TODO + }); + + // String id + test('to test the property `id`', () async { + // TODO + }); + + // String key + test('to test the property `key`', () async { + // TODO + }); + + // ServiceKind kind + test('to test the property `kind`', () async { + // TODO + }); + + // int maxValue + test('to test the property `maxValue`', () async { + // TODO + }); + + // int minValue + test('to test the property `minValue`', () async { + // TODO + }); + + // String name + test('to test the property `name`', () async { + // TODO + }); + + // int sortOrder + test('to test the property `sortOrder`', () async { + // TODO + }); + + }); +} diff --git a/packages/holzleitner_api/test/services_api_test.dart b/packages/holzleitner_api/test/services_api_test.dart new file mode 100644 index 0000000..c855643 --- /dev/null +++ b/packages/holzleitner_api/test/services_api_test.dart @@ -0,0 +1,39 @@ +import 'package:test/test.dart'; +import 'package:holzleitner_api/holzleitner_api.dart'; + + +/// tests for ServicesApi +void main() { + final instance = HolzleitnerApi().getServicesApi(); + + group(ServicesApi, () { + // Legt einen neuen Service an. + // + //Future createService(CreateServiceRequest createServiceRequest) async + test('test createService', () async { + // TODO + }); + + // Hartes Löschen. `409 Conflict`, wenn der Service noch von einer Lieferung referenziert wird — dann stattdessen deaktivieren. + // + //Future deleteService(String id) async + test('test deleteService', () async { + // TODO + }); + + // Listet die Services (sortiert nach `sortOrder`). + // + //Future listServices({ bool includeInactive }) async + test('test listServices', () async { + // TODO + }); + + // Patcht Name/Grenzen/Aktiv-Flag/Sortierung. `kind` ist nicht änderbar. + // + //Future updateService(String id, UpdateServiceRequest updateServiceRequest) async + test('test updateService', () async { + // TODO + }); + + }); +} diff --git a/packages/holzleitner_api/test/services_list_test.dart b/packages/holzleitner_api/test/services_list_test.dart new file mode 100644 index 0000000..0895672 --- /dev/null +++ b/packages/holzleitner_api/test/services_list_test.dart @@ -0,0 +1,16 @@ +import 'package:test/test.dart'; +import 'package:holzleitner_api/holzleitner_api.dart'; + +// tests for ServicesList +void main() { + final instance = ServicesListBuilder(); + // TODO add properties to the builder and call build() + + group(ServicesList, () { + // BuiltList services + test('to test the property `services`', () async { + // TODO + }); + + }); +} diff --git a/packages/holzleitner_api/test/set_delivery_service_request_test.dart b/packages/holzleitner_api/test/set_delivery_service_request_test.dart new file mode 100644 index 0000000..0624f8e --- /dev/null +++ b/packages/holzleitner_api/test/set_delivery_service_request_test.dart @@ -0,0 +1,26 @@ +import 'package:test/test.dart'; +import 'package:holzleitner_api/holzleitner_api.dart'; + +// tests for SetDeliveryServiceRequest +void main() { + final instance = SetDeliveryServiceRequestBuilder(); + // TODO add properties to the builder and call build() + + group(SetDeliveryServiceRequest, () { + // String authorCarId + test('to test the property `authorCarId`', () async { + // TODO + }); + + // bool boolValue + test('to test the property `boolValue`', () async { + // TODO + }); + + // int numericValue + test('to test the property `numericValue`', () async { + // TODO + }); + + }); +} diff --git a/packages/holzleitner_api/test/sync_contact_channel_test.dart b/packages/holzleitner_api/test/sync_contact_channel_test.dart new file mode 100644 index 0000000..c6e6d11 --- /dev/null +++ b/packages/holzleitner_api/test/sync_contact_channel_test.dart @@ -0,0 +1,27 @@ +import 'package:test/test.dart'; +import 'package:holzleitner_api/holzleitner_api.dart'; + +// tests for SyncContactChannel +void main() { + final instance = SyncContactChannelBuilder(); + // TODO add properties to the builder and call build() + + group(SyncContactChannel, () { + // ContactKind kind + test('to test the property `kind`', () async { + // TODO + }); + + // 1-basiert: spiegelt ERP-Spaltenposition (Telefon → 1, Telefon2 → 2, …). + // int position + test('to test the property `position`', () async { + // TODO + }); + + // String value + test('to test the property `value`', () async { + // TODO + }); + + }); +} diff --git a/packages/holzleitner_api/test/sync_contact_source_test.dart b/packages/holzleitner_api/test/sync_contact_source_test.dart new file mode 100644 index 0000000..bb29c7c --- /dev/null +++ b/packages/holzleitner_api/test/sync_contact_source_test.dart @@ -0,0 +1,56 @@ +import 'package:test/test.dart'; +import 'package:holzleitner_api/holzleitner_api.dart'; + +// tests for SyncContactSource +void main() { + final instance = SyncContactSourceBuilder(); + // TODO add properties to the builder and call build() + + group(SyncContactSource, () { + // String abteilung + test('to test the property `abteilung`', () async { + // TODO + }); + + // String anrede + test('to test the property `anrede`', () async { + // TODO + }); + + // BuiltList channels + test('to test the property `channels`', () async { + // TODO + }); + + // String funktion + test('to test the property `funktion`', () async { + // TODO + }); + + // String name1 + test('to test the property `name1`', () async { + // TODO + }); + + // String name2 + test('to test the property `name2`', () async { + // TODO + }); + + // String name3 + test('to test the property `name3`', () async { + // TODO + }); + + // ContactRole role + test('to test the property `role`', () async { + // TODO + }); + + // String titel + test('to test the property `titel`', () async { + // TODO + }); + + }); +} diff --git a/packages/holzleitner_api/test/update_delivery_note_request_test.dart b/packages/holzleitner_api/test/update_delivery_note_request_test.dart new file mode 100644 index 0000000..7af54a7 --- /dev/null +++ b/packages/holzleitner_api/test/update_delivery_note_request_test.dart @@ -0,0 +1,22 @@ +import 'package:test/test.dart'; +import 'package:holzleitner_api/holzleitner_api.dart'; + +// tests for UpdateDeliveryNoteRequest +void main() { + final instance = UpdateDeliveryNoteRequestBuilder(); + // TODO add properties to the builder and call build() + + group(UpdateDeliveryNoteRequest, () { + // Object-Storage-Key oder URL eines vorab hochgeladenen Bildes. + // String imageAttachment + test('to test the property `imageAttachment`', () async { + // TODO + }); + + // String text + test('to test the property `text`', () async { + // TODO + }); + + }); +} diff --git a/packages/holzleitner_api/test/update_payment_method_request_test.dart b/packages/holzleitner_api/test/update_payment_method_request_test.dart new file mode 100644 index 0000000..2b74850 --- /dev/null +++ b/packages/holzleitner_api/test/update_payment_method_request_test.dart @@ -0,0 +1,23 @@ +import 'package:test/test.dart'; +import 'package:holzleitner_api/holzleitner_api.dart'; + +// tests for UpdatePaymentMethodRequest +void main() { + final instance = UpdatePaymentMethodRequestBuilder(); + // TODO add properties to the builder and call build() + + group(UpdatePaymentMethodRequest, () { + // Wenn gesetzt: aktiv/inaktiv. Inaktive Methoden bleiben für historische Lieferungen referenzierbar, tauchen aber im Default-Listing nicht auf. + // bool active + test('to test the property `active`', () async { + // TODO + }); + + // Wenn gesetzt: neuer Anzeige-Name. + // String name + test('to test the property `name`', () async { + // TODO + }); + + }); +} diff --git a/packages/holzleitner_api/test/update_service_request_test.dart b/packages/holzleitner_api/test/update_service_request_test.dart new file mode 100644 index 0000000..0321f8b --- /dev/null +++ b/packages/holzleitner_api/test/update_service_request_test.dart @@ -0,0 +1,36 @@ +import 'package:test/test.dart'; +import 'package:holzleitner_api/holzleitner_api.dart'; + +// tests for UpdateServiceRequest +void main() { + final instance = UpdateServiceRequestBuilder(); + // TODO add properties to the builder and call build() + + group(UpdateServiceRequest, () { + // bool active + test('to test the property `active`', () async { + // TODO + }); + + // int maxValue + test('to test the property `maxValue`', () async { + // TODO + }); + + // int minValue + test('to test the property `minValue`', () async { + // TODO + }); + + // String name + test('to test the property `name`', () async { + // TODO + }); + + // int sortOrder + test('to test the property `sortOrder`', () async { + // TODO + }); + + }); +} diff --git a/pubspec.lock b/pubspec.lock index b2979ca..bc2e864 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1092,14 +1092,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.10.1" - sprintf: - dependency: transitive - description: - name: sprintf - sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" - url: "https://pub.dev" - source: hosted - version: "7.0.0" stack_trace: dependency: transitive description: @@ -1229,13 +1221,13 @@ packages: source: hosted version: "3.1.4" uuid: - dependency: transitive + dependency: "direct main" description: name: uuid - sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff + sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" url: "https://pub.dev" source: hosted - version: "4.5.1" + version: "4.5.3" vector_graphics: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index a7fac39..13497e3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -66,6 +66,7 @@ dependencies: # `tool/generate_api_client.sh` aus openapi/holzleitner.json. holzleitner_api: path: packages/holzleitner_api + uuid: ^4.5.3 dev_dependencies: build_runner: ^2.5.4 diff --git a/tool/generate_api_client.sh b/tool/generate_api_client.sh index 4484ae6..d7f4d09 100755 --- a/tool/generate_api_client.sh +++ b/tool/generate_api_client.sh @@ -51,5 +51,16 @@ pubDescription="Generierter_Dart-Client_fuer_das_Holzleitner-Rust-Backend",\ nullableFields=true,\ serializationLibrary=built_value +echo "→ build_runner für built_value-Glue (.g.dart) im Sub-Package" +# Der dart-dio-Generator schreibt die DTO-Klassen, die `_$Foo`-Konstanten +# kommen aber aus dem build_runner-Output. Ohne diesen Schritt bricht +# `flutter analyze` mit "Undefined name '_$xyz'" — ein klassischer +# Stolperstein nach Spec-Änderungen. +( + cd "${OUT}" + dart pub get + dart run build_runner build --delete-conflicting-outputs +) + echo "✓ Fertig. Code unter ${OUT}/lib" echo " Im Hauptpaket: flutter pub get (greift via path-dep zu)."