Browse code

First implementation of custom units

Benjamin Roth authored on02/09/2025 16:04:11
Showing13 changed files
... ...
@@ -14,6 +14,7 @@ use vonRotenberg\WeinanlieferungBundle\Model\WeinanlieferungLeseartModel;
14 14
 use vonRotenberg\WeinanlieferungBundle\Model\WeinanlieferungReservationModel;
15 15
 use vonRotenberg\WeinanlieferungBundle\Model\WeinanlieferungSlotsModel;
16 16
 use vonRotenberg\WeinanlieferungBundle\Model\WeinanlieferungStandortModel;
17
+use vonRotenberg\WeinanlieferungBundle\Model\WeinanlieferungUnitModel;
17 18
 use Contao\ArrayUtil;
18 19
 
19 20
 ArrayUtil::arrayInsert($GLOBALS['BE_MOD'],1,[
... ...
@@ -21,7 +22,11 @@ ArrayUtil::arrayInsert($GLOBALS['BE_MOD'],1,[
21 22
         'weinanlieferung' => [
22 23
             'tables'     => array('tl_vr_wa_standort', 'tl_vr_wa_slot', 'tl_vr_wa_rebsorte','tl_vr_wa_leseart','tl_vr_wa_lage','tl_vr_wa_reservation'),
23 24
             'stylesheet' => array('bundles/vonrotenbergweinanlieferung/css/backend.css')
24
-        ]
25
+        ],
26
+        'wa_units' => [
27
+            'tables'     => array('tl_vr_wa_unit'),
28
+            'stylesheet' => array('bundles/vonrotenbergweinanlieferung/css/backend.css')
29
+        ],
25 30
     ]
26 31
 ]);
27 32
 
... ...
@@ -32,6 +37,7 @@ $GLOBALS['TL_MODELS']['tl_vr_wa_rebsorte'] = WeinanlieferungRebsorteModel::class
32 37
 $GLOBALS['TL_MODELS']['tl_vr_wa_leseart'] = WeinanlieferungLeseartModel::class;
33 38
 $GLOBALS['TL_MODELS']['tl_vr_wa_lage'] = WeinanlieferungLageModel::class;
34 39
 $GLOBALS['TL_MODELS']['tl_vr_wa_reservation'] = WeinanlieferungReservationModel::class;
40
+$GLOBALS['TL_MODELS']['tl_vr_wa_unit'] = WeinanlieferungUnitModel::class;
35 41
 
36 42
 
37 43
 // Notification
... ...
@@ -97,7 +97,7 @@ $GLOBALS['TL_DCA']['tl_vr_wa_reservation'] = array
97 97
     'palettes' => array
98 98
     (
99 99
         '__selector__' => array('checked_in'),
100
-        'default' => 'pid,uid,behaelter,sorten,lage,ernteart,upload;{approval_legend},approved;{checkin_legend},checked_in'
100
+        'default' => 'pid,uid,behaelter,unit_id,unit_amount,sorten,lage,ernteart,upload;{approval_legend},approved;{checkin_legend},checked_in'
101 101
     ),
102 102
 
103 103
     // Subpalettes
... ...
@@ -154,6 +154,22 @@ $GLOBALS['TL_DCA']['tl_vr_wa_reservation'] = array
154 154
             'eval'       => array('rgxp'=>'natural','tl_class'=>'w50'),
155 155
             'sql'        => "int(4) unsigned NOT NULL default 0",
156 156
         ),
157
+        'unit_id' => array
158
+        (
159
+            'exclude'   => true,
160
+            'inputType' => 'select',
161
+            'foreignKey' => 'tl_vr_wa_unit.title',
162
+            'eval'      => array('includeBlankOption'=>true,'chosen'=>true,'tl_class'=>'w50'),
163
+            'sql'       => "int(10) unsigned NOT NULL default '0'",
164
+            'relation'  => array('type' => 'belongsTo', 'load' => 'lazy')
165
+        ),
166
+        'unit_amount' => array
167
+        (
168
+            'exclude'   => true,
169
+            'inputType' => 'text',
170
+            'eval'      => array('rgxp'=>'natural','tl_class'=>'w50'),
171
+            'sql'       => "int(4) unsigned NOT NULL default 0",
172
+        ),
157 173
         'sorten'       => array
158 174
         (
159 175
             'exclude'                 => true,
160 176
new file mode 100644
... ...
@@ -0,0 +1,99 @@
1
+<?php
2
+
3
+/**
4
+ * This file is part of contao-weinanlieferung-bundle.
5
+ *
6
+ * (c) vonRotenberg
7
+ *
8
+ * @license commercial
9
+ */
10
+
11
+use Contao\DC_Table;
12
+use Contao\DataContainer;
13
+
14
+$GLOBALS['TL_DCA']['tl_vr_wa_unit'] = [
15
+    // Config
16
+    'config' => [
17
+        'dataContainer' => DC_Table::class,
18
+        'sql' => [
19
+            'keys' => [
20
+                'id' => 'primary',
21
+            ],
22
+        ],
23
+    ],
24
+
25
+    // List
26
+    'list' => [
27
+        'sorting' => [
28
+            'mode' => DataContainer::MODE_SORTED,
29
+            'fields' => ['title'],
30
+            'panelLayout' => 'filter;sort,search,limit',
31
+        ],
32
+        'label' => [
33
+            'fields' => ['title', 'multiplier'],
34
+            'format' => '%s (x%s)'
35
+        ],
36
+        'global_operations' => [
37
+            'all' => [
38
+                'href' => 'act=select',
39
+                'class' => 'header_edit_all',
40
+                'attributes' => 'onclick="Backend.getScrollOffset()" accesskey="e"',
41
+            ],
42
+        ],
43
+        'operations' => [
44
+            'edit' => [
45
+                'href'  => 'act=edit',
46
+                'icon'  => 'edit.gif',
47
+            ],
48
+            'copy' => [
49
+                'href' => 'act=paste&mode=copy',
50
+                'icon' => 'copy.svg',
51
+            ],
52
+            'delete' => [
53
+                'href' => 'act=delete',
54
+                'icon' => 'delete.gif',
55
+            ],
56
+            'show' => [
57
+                'icon' => 'show.gif',
58
+            ],
59
+        ],
60
+    ],
61
+
62
+    // Palettes
63
+    'palettes' => [
64
+        'default' => 'title,multiplier',
65
+    ],
66
+
67
+    // Fields
68
+    'fields' => [
69
+        'id' => [
70
+            'sql' => "int(10) unsigned NOT NULL auto_increment",
71
+        ],
72
+        'tstamp' => [
73
+            'sql' => "int(10) unsigned NOT NULL default '0'",
74
+        ],
75
+        'title' => [
76
+            'exclude' => true,
77
+            'search' => true,
78
+            'inputType' => 'text',
79
+            'eval' => [
80
+                'mandatory' => true,
81
+                'maxlength' => 255,
82
+                'tl_class' => 'w50',
83
+            ],
84
+            'sql' => "varchar(255) NOT NULL default ''",
85
+        ],
86
+        'multiplier' => [
87
+            'exclude' => true,
88
+            'inputType' => 'text',
89
+            'eval' => [
90
+                'mandatory' => true,
91
+                'rgxp' => 'natural',
92
+                'minval' => 1,
93
+                'maxlength' => 4,
94
+                'tl_class' => 'w50',
95
+            ],
96
+            'sql' => "int(4) unsigned NOT NULL default 1",
97
+        ],
98
+    ],
99
+];
... ...
@@ -4,6 +4,28 @@
4 4
         <div class="frame__header">
5 5
             <h3>Reservierung</h3>
6 6
             <div class="grid-md u-gap-2">
7
+            <div class="grid-c-6 mb-2 mb-0-md">
8
+                <div class="u-flex u-items-center u-gap-1">
9
+                    <i class="icon-behaelter-outline"></i>
10
+                    <div class="t-label">Gebuchte Behälterkapazität</div>
11
+                </div>
12
+                <div class="">{{ buchung.behaelter }}</div>
13
+            </div>
14
+            {% if buchung is defined %}
15
+            <div class="grid-c-6 mb-2 mb-0-md">
16
+                <div class="u-flex u-items-center u-gap-1">
17
+                    <i class="icon-behaelter-outline"></i>
18
+                    <div class="t-label">Einheit</div>
19
+                </div>
20
+                {% set uTitle = buchung.unit_title|default('Behälter') %}
21
+                {% set uAmount = buchung.unit_amount_display|default(0) %}
22
+                {% if uAmount > 0 %}
23
+                    <div class="">{{ uAmount }} × {{ uTitle }}</div>
24
+                {% else %}
25
+                    <div class="">{{ buchung.behaelter }} × {{ 'Behälter' }}</div>
26
+                {% endif %}
27
+            </div>
28
+            {% endif %}
7 29
                 <div class="grid-c-6 mb-2 mb-0-md">
8 30
                     <div class="u-flex u-items-center u-gap-1">
9 31
                         <i class="icon-uhr-outline"></i>
... ...
@@ -42,22 +64,44 @@
42 64
             <form hx-post="/_ajax/vr_wa/v1/slot?do=updateReservation" enctype="multipart/form-data">
43 65
                 <input type="hidden" name="id" value="{{ id }}">
44 66
                 <fieldset>
45
-                    <label for="bok-behaelter"><strong>Liefernde Behältermenge</strong><sup class="text-danger">*</sup></label>
46
-                    <select id="bok-behaelter" name="behaelter" required>
47
-                        <option value="">-</option>
48
-                        {% for option in buchen.behaelter %}
49
-                            {% if option > slot.behaelterOcThreshold %}
50
-                                <option value="{{ option }}"{{ buchung.behaelter == option ? ' selected' : '' }} data-oc>{{ option }} ({{ 'MSC.wa_approval_needed'|trans([], 'contao_default') }})</option>
51
-                            {% else %}
52
-                                <option value="{{ option }}"{{ buchung.behaelter == option ? ' selected' : '' }}>{{ option }}</option>
53
-                            {% endif %}
67
+                    <label for="bok-unit"><strong>Einheit</strong><sup class="text-danger">*</sup></label>
68
+                    <select id="bok-unit" name="unit_id" required>
69
+                        {% for unit in buchen.units %}
70
+                            <option value="{{ unit.id }}" data-multiplier="{{ unit.multiplier }}"{% if buchung.unit_id == unit.id %} selected{% endif %}>{{ unit.title }}</option>
54 71
                         {% endfor %}
55
-                        {#{% for option in buchen.behaelter %}
56
-                            <option value="{{ option }}"{{ buchung.behaelter == option ? ' selected' : '' }}>{{ option }}</option>
57
-                        {% endfor %}#}
58 72
                     </select>
59 73
                 </fieldset>
60 74
                 <fieldset>
75
+                    <label for="bok-unit-amount"><strong>Anzahl</strong><sup class="text-danger">*</sup></label>
76
+                    <select id="bok-unit-amount" name="unit_amount" required>
77
+                    </select>
78
+                    {#<div class="text-sm text-muted">Verbleibende Kapazität (Basis): {{ slot.behaelterAvailable }}</div>#}
79
+                </fieldset>
80
+                <script>
81
+                    (function(){
82
+                        var container = document.getElementById('wa-booking-{{ id }}');
83
+                        if(!container) return;
84
+                        var units = {{ buchen.units|json_encode|raw }};
85
+                        var unitSel = container.querySelector('#bok-unit');
86
+                        var amtSel = container.querySelector('#bok-unit-amount');
87
+                        var selectedAmount = {{ buchung.unit_amount|default(0) }};
88
+                        function fillAmounts(){
89
+                            if(!unitSel||!amtSel) return;
90
+                            var id = parseInt(unitSel.value||0,10);
91
+                            var u = units.find(function(x){return x.id == id;});
92
+                            if(!u){ amtSel.innerHTML=''; return; }
93
+                            var html = '<option value="">-</option>';
94
+                            for(var i=1;i<=u.max_amount;i++){ html += '<option value="'+i+'"'+(selectedAmount==i?' selected':'')+'>'+i+'</option>'; }
95
+                            amtSel.innerHTML = html;
96
+                        }
97
+                        if(unitSel){
98
+                            unitSel.onchange = null;
99
+                            unitSel.addEventListener('change', function(){ selectedAmount = 0; fillAmounts();});
100
+                        }
101
+                        fillAmounts();
102
+                    })();
103
+                </script>
104
+                <fieldset>
61 105
                   <legend>Anliefernde Rebsorte(n)<sup class="text-danger">*</sup></legend>
62 106
                     {% for value,label in buchen.sorten %}
63 107
                         <label><input type="checkbox" name="sorten[]" value="{{ value }}"{{ value in buchung.sorten|keys ? ' checked' : '' }}> <span class="checkable">{{ label }}</span></label><br>
... ...
@@ -101,6 +145,30 @@
101 145
         <script>
102 146
             (function ($) {
103 147
 
148
+                // Define an idempotent initializer for this modal instance
149
+                window.initWaBookingDetails{{ id }} = function() {
150
+                    var container = document.getElementById('wa-booking-{{ id }}');
151
+                    if(!container) return;
152
+                    var units = {{ buchen.units|json_encode|raw }};
153
+                    var unitSel = container.querySelector('#bok-unit');
154
+                    var amtSel = container.querySelector('#bok-unit-amount');
155
+                    var selectedAmount = {{ buchung.unit_amount|default(0) }};
156
+                    function fillAmounts(){
157
+                        if(!unitSel||!amtSel) return;
158
+                        var id = parseInt(unitSel.value||0,10);
159
+                        var u = units.find(function(x){return x.id == id;});
160
+                        if(!u){ amtSel.innerHTML=''; return; }
161
+                        var html = '<option value="">-</option>';
162
+                        for(var i=1;i<=u.max_amount;i++){ html += '<option value="'+i+'"'+(selectedAmount==i?' selected':'')+'>'+i+'</option>'; }
163
+                        amtSel.innerHTML = html;
164
+                    }
165
+                    if(unitSel){
166
+                        unitSel.onchange = null;
167
+                        unitSel.addEventListener('change', function(){ selectedAmount = 0; fillAmounts();});
168
+                    }
169
+                    fillAmounts();
170
+                };
171
+
104 172
                 window.modals = window.modals || []
105 173
                 window.modals.wa_bookings = window.modals.wa_bookings || []
106 174
 
... ...
@@ -116,11 +184,15 @@
116 184
                     overlay: true,
117 185
                     closeOnClick: false,
118 186
                     zIndex: 'auto',
119
-                    addClass: ''
187
+                    addClass: '',
188
+                    onOpen: function(){
189
+                        if (window.initWaBookingDetails{{ id }}) { window.initWaBookingDetails{{ id }}(); }
190
+                    }
120 191
                 }).open();
121 192
                 } else {
122 193
                     window.modals.wa_bookings.details{{ id }}.content.empty();
123 194
                     window.modals.wa_bookings.details{{ id }}.setContent($('#wa-booking-{{ id }}')).open();
195
+                    if (window.initWaBookingDetails{{ id }}) { window.initWaBookingDetails{{ id }}(); }
124 196
                 }
125 197
 
126 198
             })(jQuery);
... ...
@@ -27,6 +27,22 @@
27 27
                     </div>
28 28
                     <div class="">{{ buchung.behaelter }}</div>
29 29
                 </div>
30
+                <div class="grid-c-6 mb-2 mb-0-md">
31
+                    <div class="u-flex u-items-center u-gap-1">
32
+                        <i class="icon-behaelter-outline"></i>
33
+                        <div class="t-label">Erforderliche Eingaben</div>
34
+                    </div>
35
+                    <div class="">{{ checkin.expected }}</div>
36
+                </div>
37
+                <div class="grid-c-6 mb-2 mb-0-md">
38
+                    <div class="u-flex u-items-center u-gap-1">
39
+                        <i class="icon-behaelter-outline"></i>
40
+                        <div class="t-label">Einheit</div>
41
+                    </div>
42
+                    {% set uTitle = checkin.unit_title|default('Behälter') %}
43
+                    {% set uAmount = checkin.unit_amount_display|default(checkin.behaelter) %}
44
+                    <div class="">{{ uAmount }} × {{ uTitle }}</div>
45
+                </div>
30 46
 
31 47
                 <div class="grid-c-6 mb-2 mb-0-md">
32 48
                     <div class="u-flex u-items-center u-gap-1">
... ...
@@ -64,7 +80,8 @@
64 80
                 </datalist>
65 81
 
66 82
                 <div class="grid-md u-gap-2">
67
-                    {% for i in 1..checkin.behaelter %}
83
+                    {% set expected = checkin.expected|default(checkin.behaelter) %}
84
+                    {% for i in 1..expected %}
68 85
                         <div class="grid-c-12">
69 86
                             <div class="grid-md u-gap-2">
70 87
                                 <div class="grid-c-6">
... ...
@@ -90,6 +90,9 @@
90 90
                                     <div class="u-flex u-items-center u-gap-1">
91 91
                                         <i class="icon-behaelter-outline"></i>
92 92
                                         {{ reservation.behaelter }}
93
+                                        {% set uTitle = reservation.unit_title|default('Behälter') %}
94
+                                        {% set uAmount = reservation.unit_amount_display|default(reservation.behaelter) %}
95
+                                        <span class="text-sm text-muted"> ({{ uAmount }} × {{ uTitle }})</span>
93 96
                                     </div>
94 97
                                 </div>
95 98
                                 <div class="col-6">
... ...
@@ -138,22 +141,45 @@
138 141
                     <form hx-post="/_ajax/vr_wa/v1/slot?do=reservate" enctype="multipart/form-data">
139 142
                         <input type="hidden" name="id" value="{{ id }}">
140 143
                         <fieldset>
141
-                            <label for="res-behaelter"><strong>Liefernde Behältermenge<sup class="text-danger">*</sup></strong></label>
142
-                            <select id="res-behaelter" name="behaelter" required>
143
-                                <option value="">-</option>
144
-                                {% for option in buchen.behaelter %}
145
-                                    {% if option > slot.behaelterAvailable %}
146
-                                        <option value="{{ option }}" data-oc>{{ option }} ({{ 'MSC.wa_approval_needed'|trans([], 'contao_default') }})</option>
147
-                                    {% else %}
148
-                                        <option value="{{ option }}">{{ option }}</option>
149
-                                    {% endif %}
144
+                            <label for="res-unit"><strong>Einheit<sup class="text-danger">*</sup></strong></label>
145
+                            <select id="res-unit" name="unit_id" required>
146
+                                {% for unit in buchen.units %}
147
+                                    <option value="{{ unit.id }}" data-multiplier="{{ unit.multiplier }}">{{ unit.title }}</option>
150 148
                                 {% endfor %}
151
-                                {#{% for option in buchen.behaelter %}
152
-                                    <option value="{{ option }}">{{ option }}</option>
153
-                                {% endfor %}#}
154 149
                             </select>
155 150
                         </fieldset>
156 151
                         <fieldset>
152
+                            <label for="res-unit-amount"><strong>Anzahl<sup class="text-danger">*</sup></strong></label>
153
+                            <select id="res-unit-amount" name="unit_amount" required>
154
+                                <!-- options will be populated by script based on selected unit and capacity -->
155
+                            </select>
156
+                            {#<div class="text-sm text-muted">Verbleibende Kapazität (Basis): {{ slot.behaelterAvailable }}</div>#}
157
+                        </fieldset>
158
+                        <script>
159
+                            (function(){
160
+                                var container = document.getElementById('wa-slot-{{ id }}');
161
+                                if(!container) return;
162
+                                var units = {{ buchen.units|json_encode|raw }};
163
+                                var unitSel = container.querySelector('#res-unit');
164
+                                var amtSel = container.querySelector('#res-unit-amount');
165
+                                function fillAmounts(){
166
+                                    if(!unitSel||!amtSel) return;
167
+                                    var id = parseInt(unitSel.value||0,10);
168
+                                    var u = units.find(function(x){return x.id == id;});
169
+                                    if(!u){ amtSel.innerHTML=''; return; }
170
+                                    var html = '<option value="">-</option>';
171
+                                    for(var i=1;i<=u.max_amount;i++){ html += '<option value="'+i+'">'+i+'</option>'; }
172
+                                    amtSel.innerHTML = html;
173
+                                }
174
+                                if(unitSel){
175
+                                    // avoid duplicate listeners by resetting
176
+                                    unitSel.onchange = null;
177
+                                    unitSel.addEventListener('change', fillAmounts, { once:false });
178
+                                }
179
+                                fillAmounts();
180
+                            })();
181
+                        </script>
182
+                        <fieldset>
157 183
                           <legend>Anliefernde Rebsorte(n)<sup class="text-danger">*</sup></legend>
158 184
                             {% for value,label in buchen.sorten %}
159 185
                                 <label><input type="checkbox" name="sorten[]" value="{{ value }}"> <span class="checkable">{{ label }}</span></label><br>
... ...
@@ -195,6 +221,29 @@
195 221
         <script>
196 222
             (function ($) {
197 223
 
224
+                // Define an idempotent initializer for this modal instance
225
+                window.initWaSlotDetails{{ id }} = function() {
226
+                    var container = document.getElementById('wa-slot-{{ id }}');
227
+                    if(!container) return;
228
+                    var units = {{ buchen.units|json_encode|raw }};
229
+                    var unitSel = container.querySelector('#res-unit');
230
+                    var amtSel = container.querySelector('#res-unit-amount');
231
+                    function fillAmounts(){
232
+                        if(!unitSel||!amtSel) return;
233
+                        var id = parseInt(unitSel.value||0,10);
234
+                        var u = units.find(function(x){return x.id == id;});
235
+                        if(!u){ amtSel.innerHTML=''; return; }
236
+                        var html = '<option value="">-</option>';
237
+                        for(var i=1;i<=u.max_amount;i++){ html += '<option value="'+i+'">'+i+'</option>'; }
238
+                        amtSel.innerHTML = html;
239
+                    }
240
+                    if(unitSel){
241
+                        unitSel.onchange = null;
242
+                        unitSel.addEventListener('change', fillAmounts, { once:false });
243
+                    }
244
+                    fillAmounts();
245
+                };
246
+
198 247
                 window.modals = window.modals || []
199 248
                 window.modals.wa_slots = window.modals.wa_slots || []
200 249
 
... ...
@@ -210,11 +259,15 @@
210 259
                     overlay: true,
211 260
                     closeOnClick: false,
212 261
                     zIndex: 'auto',
213
-                    addClass: ''
262
+                    addClass: '',
263
+                    onOpen: function(){
264
+                        if (window.initWaSlotDetails{{ id }}) { window.initWaSlotDetails{{ id }}(); }
265
+                    }
214 266
                 }).open();
215 267
                 } else {
216 268
                     window.modals.wa_slots.details{{ id }}.content.empty();
217 269
                     window.modals.wa_slots.details{{ id }}.setContent($('#wa-slot-{{ id }}')).open();
270
+                    if (window.initWaSlotDetails{{ id }}) { window.initWaSlotDetails{{ id }}(); }
218 271
                 }
219 272
 
220 273
             })(jQuery);
... ...
@@ -134,10 +134,16 @@
134 134
                                                                     <div class="t-label">Anliefernde Sorten</div>
135 135
                                                                     {{ booking.sorte|join(', ') }}
136 136
                                                                 </div>
137
-                                                                <div class="col-3 behaelter icon-behaelter-outline">
138
-                                                                    <div class="t-label">Gebuchte Behälterkapazität</div>
137
+                                                                <div class="col-1 behaelter icon-behaelter-outline">
138
+                                                                    <div class="t-label">Gebucht</div>
139 139
                                                                     {{ booking.behaelter }}
140 140
                                                                 </div>
141
+                                                                <div class="col-2 behaelter icon-behaelter-outline">
142
+                                                                    <div class="t-label">Einheit</div>
143
+                                                                    {% set uTitle = booking.unit_title|default('Behälter') %}
144
+                                                                    {% set uAmount = booking.unit_amount_display|default(booking.behaelter) %}
145
+                                                                    {{ uAmount }} × {{ uTitle }}
146
+                                                                </div>
141 147
                                                                 <div class="additional-facts col-12 p-0 m-0">
142 148
                                                                     <div class="row u-items-flex-start pb-0">
143 149
                                                                         <div class="col-1">
... ...
@@ -69,6 +69,9 @@
69 69
                                                     <i class="icon-behaelter-outline"></i>
70 70
                                                     <span class="t-label">Gebuchte Behälterkapazität</span>
71 71
                                                     {{ booking.behaelter }}
72
+                                                    {% set uTitle = booking.unit_title|default('Behälter') %}
73
+                                                    {% set uAmount = booking.unit_amount_display|default(booking.behaelter) %}
74
+                                                    <span class="text-sm text-muted"> ({{ uAmount }} × {{ uTitle }})</span>
72 75
                                                 </div>
73 76
                                             </div>
74 77
                                             <div class="grid-c-6 rebsorten bg-white p-1">
... ...
@@ -33,6 +33,7 @@ use vonRotenberg\WeinanlieferungBundle\Model\WeinanlieferungLeseartModel;
33 33
 use vonRotenberg\WeinanlieferungBundle\Model\WeinanlieferungRebsorteModel;
34 34
 use vonRotenberg\WeinanlieferungBundle\Model\WeinanlieferungReservationModel;
35 35
 use vonRotenberg\WeinanlieferungBundle\Model\WeinanlieferungSlotsModel;
36
+use vonRotenberg\WeinanlieferungBundle\Model\WeinanlieferungUnitModel;
36 37
 
37 38
 /**
38 39
  * @Route("contao/weinanlieferung/buchungsliste", name=WeinanlieferungBookingsController::class, defaults={"_scope" = "backend"})
... ...
@@ -309,6 +310,22 @@ class WeinanlieferungBookingsController extends AbstractController
309 310
                         }
310 311
                     }
311 312
 
313
+                    // Compute unit display fields for backend list
314
+                    $unitTitle = 'Behälter';
315
+                    $unitAmountDisplay = (int) $booking->behaelter;
316
+                    if (!empty($booking->unit_id) && (int)$booking->unit_id > 0) {
317
+                        $unitModel = WeinanlieferungUnitModel::findByPk((int)$booking->unit_id);
318
+                        if (null !== $unitModel) {
319
+                            $unitTitle = (string)$unitModel->title;
320
+                        }
321
+                        $unitAmountDisplay = (int) ($booking->unit_amount ?: 0);
322
+                        if ($unitAmountDisplay <= 0) {
323
+                            // Fallback just in case: derive amount by multiplier if available
324
+                            $mult = (int) ($unitModel ? $unitModel->multiplier : 0);
325
+                            $unitAmountDisplay = $mult > 0 ? max(1, (int) ($booking->behaelter / $mult)) : (int) $booking->behaelter;
326
+                        }
327
+                    }
328
+
312 329
                     $arrData['days'][$day->dayBegin][$Slot->pid]['times'][$Slot->time]['items'][] = array_merge($booking->row(), [
313 330
                         'sorte'              => $arrSorten,
314 331
                         'ernteart' => $arrErnteart,
... ...
@@ -317,7 +334,9 @@ class WeinanlieferungBookingsController extends AbstractController
317 334
                         'standort' => $strStandort,
318 335
                         'member' => $booking->getRelated('uid') !== null ? $booking->getRelated('uid')->row() : null,
319 336
                         'behaelter_numbers' => $behaelterNumbers,
320
-                        'upload_file' => $uploadFile
337
+                        'upload_file' => $uploadFile,
338
+                        'unit_title' => $unitTitle,
339
+                        'unit_amount_display' => $unitAmountDisplay,
321 340
                     ]);
322 341
                 }
323 342
             }
... ...
@@ -39,6 +39,7 @@ use vonRotenberg\WeinanlieferungBundle\Model\WeinanlieferungLeseartModel;
39 39
 use vonRotenberg\WeinanlieferungBundle\Model\WeinanlieferungRebsorteModel;
40 40
 use vonRotenberg\WeinanlieferungBundle\Model\WeinanlieferungReservationModel;
41 41
 use vonRotenberg\WeinanlieferungBundle\Model\WeinanlieferungSlotsModel;
42
+use vonRotenberg\WeinanlieferungBundle\Model\WeinanlieferungUnitModel;
42 43
 
43 44
 /**
44 45
  * @Route("/_ajax/vr_wa/v1/slot", name="vr_wa_slot_ajax", defaults={"_scope" = "frontend", "_token_check" = false})
... ...
@@ -154,8 +155,25 @@ class SlotAjaxController extends AbstractController
154 155
                 {
155 156
                     $arrSortenBooked = $Sorten->fetchEach('title');
156 157
                 }*/
158
+                // Compute unit display fields for this reservation
159
+                $unitTitle = $GLOBALS['TL_LANG']['MSC']['wa_unit_base'] ?? 'Behälter';
160
+                $unitAmountDisplay = (int) $reservation->behaelter;
161
+                if ((int)$reservation->unit_id > 0) {
162
+                    $unitModel = WeinanlieferungUnitModel::findByPk((int)$reservation->unit_id);
163
+                    if (null !== $unitModel) {
164
+                        $unitTitle = (string)$unitModel->title;
165
+                    }
166
+                    $unitAmountDisplay = (int) ($reservation->unit_amount ?: 0);
167
+                    if ($unitAmountDisplay <= 0) {
168
+                        $mult = (int) ($unitModel ? $unitModel->multiplier : 0);
169
+                        $unitAmountDisplay = $mult > 0 ? max(1, (int) ($reservation->behaelter / $mult)) : (int) $reservation->behaelter;
170
+                    }
171
+                }
172
+
157 173
                 $arrReservations[] = array_merge($reservation->row(),[
158
-                    'sorten' => $arrSortenBooked
174
+                    'sorten' => $arrSortenBooked,
175
+                    'unit_title' => $unitTitle,
176
+                    'unit_amount_display' => $unitAmountDisplay,
159 177
                 ]);
160 178
             }
161 179
         }
... ...
@@ -207,6 +225,7 @@ class SlotAjaxController extends AbstractController
207 225
             'buchen' => [
208 226
                 'buchbar' => (boolean) ($Slot->behaelter*3 > $intReservedBehaelter),
209 227
                 'behaelter' => range(min($intAvailableBehaelter,1),$Slot->behaelter*3-$intReservedBehaelter),
228
+                'units' => $this->getAvailableUnitsForCapacity($intAvailableBehaelter),
210 229
                 'sorten' => $arrSorten,
211 230
                 'lage' => $arrLage,
212 231
                 'ernteart' => $arrErnteart,
... ...
@@ -277,6 +296,7 @@ class SlotAjaxController extends AbstractController
277 296
             'buchen' => [
278 297
                 'buchbar' => (boolean) $intAvailableBehaelter,
279 298
                 'behaelter' => range(min($intAvailableBehaelter,1),$intAvailableBehaelter),
299
+                'units' => $this->getAvailableUnitsForCapacity($intAvailableBehaelter),
280 300
                 'sorten' => $arrSorten
281 301
             ],
282 302
         ];
... ...
@@ -396,7 +416,9 @@ class SlotAjaxController extends AbstractController
396 416
         }
397 417
 
398 418
         $intReservedBehaelter = $Slot->getReservedBehaelter();
399
-        $intAvailableBehaelter = max($Booking->behaelter,$Slot->getAvailableBehaelter());
419
+        // While editing, available base units should include the current booking's base units,
420
+        // because the edit will override the former amount.
421
+        $intAvailableBehaelter = max(0, $Slot->getAvailableBehaelter() + (int) $Booking->behaelter);
400 422
         $intOcTreshold = $intAvailableBehaelter - $intReservedBehaelter + $Slot->behaelter;
401 423
 
402 424
         $arrData = array_merge($arrData,[
... ...
@@ -413,11 +435,30 @@ class SlotAjaxController extends AbstractController
413 435
                 'sorten' => $arrSortenBooked,
414 436
                 'ernteart' => $arrErnteartBooked,
415 437
                 'lage' => $arrLagenBooked,
438
+                // display fields
439
+                'unit_title' => (function() use ($Booking) {
440
+                    if ((int)$Booking->unit_id > 0) {
441
+                        $m = WeinanlieferungUnitModel::findByPk((int)$Booking->unit_id);
442
+                        return $m ? (string)$m->title : ($GLOBALS['TL_LANG']['MSC']['wa_unit_base'] ?? 'Behälter');
443
+                    }
444
+                    return $GLOBALS['TL_LANG']['MSC']['wa_unit_base'] ?? 'Behälter';
445
+                })(),
446
+                'unit_amount_display' => (function() use ($Booking) {
447
+                    if ((int)$Booking->unit_id > 0) {
448
+                        $m = WeinanlieferungUnitModel::findByPk((int)$Booking->unit_id);
449
+                        $amount = (int) ($Booking->unit_amount ?: 0);
450
+                        if ($amount > 0) return $amount;
451
+                        $mult = (int) ($m ? $m->multiplier : 0);
452
+                        return $mult > 0 ? max(1, (int) ($Booking->behaelter / $mult)) : (int) $Booking->behaelter;
453
+                    }
454
+                    return (int) $Booking->behaelter;
455
+                })(),
416 456
             ]),
417 457
             'standort' => $Slot->getRelated('pid'),
418 458
             'buchen' => [
419 459
                 'buchbar' => (boolean) $intAvailableBehaelter,
420 460
                 'behaelter' => range(min($intAvailableBehaelter,1),$Slot->behaelter*3-$Slot->getReservedBehaelter()+$Booking->behaelter),
461
+                'units' => $this->getAvailableUnitsForCapacity($intAvailableBehaelter),
421 462
                 'sorten' => $arrSortenAvailable,
422 463
                 'ernteart' => $arrErnteartAvailable,
423 464
                 'lage' => $arrLagenAvailable,
... ...
@@ -463,23 +504,41 @@ class SlotAjaxController extends AbstractController
463 504
             return new Response('Missing parameter',412);
464 505
         }
465 506
 
466
-        // Form validation
507
+        // Form validation and unit capacity
467 508
         if (($Slot = WeinanlieferungSlotsModel::findByPk($_REQUEST['id'])) !== null)
468 509
         {
469
-//            if (Input::post('behaelter') > $Slot->getAvailableBehaelter())
470
-            if (Input::post('behaelter') > $Slot->behaelter*3-$Slot->getReservedBehaelter())
510
+            $unitId = (int) Input::post('unit_id');
511
+            $unitAmount = (int) Input::post('unit_amount');
512
+            $multiplier = 1;
513
+            if ($unitId > 0) {
514
+                if (($u = WeinanlieferungUnitModel::findByPk($unitId)) !== null) {
515
+                    $multiplier = max(1, (int) $u->multiplier);
516
+                }
517
+            }
518
+            $baseUnitsRequested = $unitAmount > 0 ? $unitAmount * $multiplier : (int) Input::post('behaelter');
519
+            if ($baseUnitsRequested > $Slot->behaelter*3 - $Slot->getReservedBehaelter())
471 520
             {
472 521
                 return $this->renderDetails(false,sprintf('<div class="toast toast--danger mx-0">Fehler: Es sind mittlerweile nur noch %s Behälter verfügbar.</div>',$Slot->getAvailableBehaelter()));
473 522
             }
474 523
         }
475 524
         $arrError = [];
476
-        foreach (['behaelter','sorten','ernteart','lage'] as $field)
525
+        foreach (['sorten','ernteart','lage'] as $field)
477 526
         {
478 527
             if (empty(Input::post($field)))
479 528
             {
480 529
                 $arrError = [$field];
481 530
             }
482 531
         }
532
+        // Require either (unit selection (allowing 0) with unit_amount, or behaelter fallback)
533
+        $postedUnitId = Input::post('unit_id');
534
+        $hasUnitId = ($postedUnitId !== null && $postedUnitId !== '' && $postedUnitId !== false);
535
+        if (!$hasUnitId || empty(Input::post('unit_amount')))
536
+        {
537
+            if (empty(Input::post('behaelter')))
538
+            {
539
+                $arrError[] = 'behaelter';
540
+            }
541
+        }
483 542
 
484 543
         if (count($arrError))
485 544
         {
... ...
@@ -523,11 +582,24 @@ class SlotAjaxController extends AbstractController
523 582
             $arrData['approved_on'] = $time;
524 583
         }
525 584
 
585
+        // Determine fields to store
586
+        $unitId = (int) Input::post('unit_id');
587
+        $unitAmount = (int) Input::post('unit_amount');
588
+        $multiplier = 1;
589
+        if ($unitId > 0) {
590
+            if (($u = WeinanlieferungUnitModel::findByPk($unitId)) !== null) {
591
+                $multiplier = max(1, (int) $u->multiplier);
592
+            }
593
+        }
594
+        $baseUnits = $unitAmount > 0 ? $unitAmount * $multiplier : (int) Input::post('behaelter');
595
+
526 596
         $arrData = array_merge($arrData,[
527 597
             'pid' => $_REQUEST['id'],
528 598
             'tstamp' => $time,
529 599
             'uid' => FrontendUser::getInstance()->id,
530
-            'behaelter' => Input::post('behaelter'),
600
+            'behaelter' => $baseUnits,
601
+            'unit_id' => $unitId,
602
+            'unit_amount' => $unitAmount,
531 603
             'sorten' => $arrSorten,
532 604
             'ernteart' => $arrErnteart,
533 605
             'lage' => $arrLage
... ...
@@ -588,20 +660,37 @@ class SlotAjaxController extends AbstractController
588 660
         /** @var WeinanlieferungSlotsModel $Slot */
589 661
         if (($Slot = $Reservation->getRelated('pid')) !== null)
590 662
         {
591
-//            if (Input::post('behaelter') > $Slot->getAvailableBehaelter()+$Reservation->behaelter)
592
-            if (Input::post('behaelter') > $Slot->behaelter*3-$Slot->getReservedBehaelter()+$Reservation->behaelter)
663
+            $unitId = (int) Input::post('unit_id');
664
+            $unitAmount = (int) Input::post('unit_amount');
665
+            $multiplier = 1;
666
+            if ($unitId > 0) {
667
+                if (($u = WeinanlieferungUnitModel::findByPk($unitId)) !== null) {
668
+                    $multiplier = max(1, (int) $u->multiplier);
669
+                }
670
+            }
671
+            $baseUnitsRequested = $unitAmount > 0 ? $unitAmount * $multiplier : (int) Input::post('behaelter');
672
+            if ($baseUnitsRequested > $Slot->behaelter*3 - $Slot->getReservedBehaelter() + $Reservation->behaelter)
593 673
             {
594 674
                 return $this->renderBooking(false,sprintf('<div class="toast toast--danger mx-0">Fehler: Es sind mittlerweile nur noch %s Behälter verfügbar.</div>',$Slot->getAvailableBehaelter()+$Reservation->behaelter));
595 675
             }
596 676
         }
597 677
         $arrError = [];
598
-        foreach (['behaelter','sorten','ernteart','lage'] as $field)
678
+        foreach (['sorten','ernteart','lage'] as $field)
599 679
         {
600 680
             if (empty(Input::post($field)))
601 681
             {
602 682
                 $arrError = [$field];
603 683
             }
604 684
         }
685
+        $postedUnitId = Input::post('unit_id');
686
+        $hasUnitId = ($postedUnitId !== null && $postedUnitId !== '' && $postedUnitId !== false);
687
+        if (!$hasUnitId || empty(Input::post('unit_amount')))
688
+        {
689
+            if (empty(Input::post('behaelter')))
690
+            {
691
+                $arrError[] = 'behaelter';
692
+            }
693
+        }
605 694
 
606 695
         if (count($arrError))
607 696
         {
... ...
@@ -644,7 +733,18 @@ class SlotAjaxController extends AbstractController
644 733
         }
645 734
 
646 735
         $Reservation->tstamp = $time;
647
-        $Reservation->behaelter = Input::post('behaelter');
736
+        // recompute base units
737
+        $unitId = (int) Input::post('unit_id');
738
+        $unitAmount = (int) Input::post('unit_amount');
739
+        $multiplier = 1;
740
+        if ($unitId > 0) {
741
+            if (($u = WeinanlieferungUnitModel::findByPk($unitId)) !== null) {
742
+                $multiplier = max(1, (int) $u->multiplier);
743
+            }
744
+        }
745
+        $Reservation->behaelter = $unitAmount > 0 ? $unitAmount * $multiplier : (int) Input::post('behaelter');
746
+        $Reservation->unit_id = $unitId;
747
+        $Reservation->unit_amount = $unitAmount;
648 748
         $Reservation->sorten = $arrSorten;
649 749
         $Reservation->ernteart = $arrErnteart;
650 750
         $Reservation->lage = $arrLage;
... ...
@@ -768,6 +868,24 @@ class SlotAjaxController extends AbstractController
768 868
             'standort' => $Standort,
769 869
             'checkin' => [
770 870
                 'behaelter' => $Booking->behaelter,
871
+                'expected' => ($Booking->unit_amount ?? 0) > 0 ? (int)$Booking->unit_amount : (int)$Booking->behaelter,
872
+                'unit_title' => (function() use ($Booking) {
873
+                    if ((int)$Booking->unit_id > 0) {
874
+                        $m = WeinanlieferungUnitModel::findByPk((int)$Booking->unit_id);
875
+                        return $m ? (string)$m->title : ($GLOBALS['TL_LANG']['MSC']['wa_unit_base'] ?? 'Behälter');
876
+                    }
877
+                    return $GLOBALS['TL_LANG']['MSC']['wa_unit_base'] ?? 'Behälter';
878
+                })(),
879
+                'unit_amount_display' => (function() use ($Booking) {
880
+                    if ((int)$Booking->unit_id > 0) {
881
+                        $m = WeinanlieferungUnitModel::findByPk((int)$Booking->unit_id);
882
+                        $amount = (int) ($Booking->unit_amount ?: 0);
883
+                        if ($amount > 0) return $amount;
884
+                        $mult = (int) ($m ? $m->multiplier : 0);
885
+                        return $mult > 0 ? max(1, (int) ($Booking->behaelter / $mult)) : (int) $Booking->behaelter;
886
+                    }
887
+                    return (int) $Booking->behaelter;
888
+                })(),
771 889
             ],
772 890
             'member' => $memberModel ? $memberModel->row() : null,
773 891
             'current_member' => $currentMemberModel ? $currentMemberModel->row() : null
... ...
@@ -801,14 +919,15 @@ class SlotAjaxController extends AbstractController
801 919
 
802 920
         // Validate that we have the correct number of behaelter numbers
803 921
         $behaelterNumbers = Input::post('behaelter_numbers');
804
-        if (!is_array($behaelterNumbers) || count($behaelterNumbers) != $Booking->behaelter)
922
+        $expectedCount = ($Booking->unit_amount ?? 0) > 0 ? (int)$Booking->unit_amount : (int)$Booking->behaelter;
923
+        if (!is_array($behaelterNumbers) || count($behaelterNumbers) != $expectedCount)
805 924
         {
806 925
             // Prepare form data to preserve input values
807 926
             $formData = [
808 927
                 'behaelter_numbers' => $behaelterNumbers ?: [],
809 928
                 'member_numbers' => Input::post('member_numbers') ?: []
810 929
             ];
811
-            return $this->renderCheckin(false, '<div class="toast toast--danger mx-0">Bitte wählen Sie für jeden Behälter eine Nummer aus.</div>', $formData);
930
+            return $this->renderCheckin(false, '<div class="toast toast--danger mx-0">Bitte wählen Sie für jede Einheit eine Nummer aus.</div>', $formData);
812 931
         }
813 932
 
814 933
         // Get member numbers from the form
... ...
@@ -1117,4 +1236,39 @@ class SlotAjaxController extends AbstractController
1117 1236
         // If we got here, the number is valid
1118 1237
         return new Response(json_encode(['valid' => true]), 200, ['Content-Type' => 'application/json']);
1119 1238
     }
1239
+
1240
+    protected function getAvailableUnitsForCapacity(int $availableBaseUnits): array
1241
+    {
1242
+        // Always include the base unit (Behälter) with multiplier 1
1243
+        $units = [];
1244
+        $units[] = [
1245
+            'id' => 0,
1246
+            'title' => $GLOBALS['TL_LANG']['MSC']['wa_unit_base'] ?? 'Behälter',
1247
+            'multiplier' => 1,
1248
+            'max_amount' => $availableBaseUnits, // one check-in per base unit
1249
+        ];
1250
+
1251
+        // Load custom units from DB
1252
+        if (($all = WeinanlieferungUnitModel::findAll()) !== null) {
1253
+            foreach ($all as $unit) {
1254
+                $mult = (int)($unit->multiplier ?? 0);
1255
+                if ($mult < 1) {
1256
+                    continue; // skip invalid
1257
+                }
1258
+                // A unit fits if at least one of it can be placed into remaining capacity
1259
+                $maxAmount = intdiv(max(0, $availableBaseUnits), $mult);
1260
+                if ($maxAmount < 1) {
1261
+                    continue; // does not fit current capacity
1262
+                }
1263
+                $units[] = [
1264
+                    'id' => (int)$unit->id,
1265
+                    'title' => (string)$unit->title,
1266
+                    'multiplier' => $mult,
1267
+                    'max_amount' => $maxAmount,
1268
+                ];
1269
+            }
1270
+        }
1271
+
1272
+        return $units;
1273
+    }
1120 1274
 }
... ...
@@ -98,6 +98,21 @@ class WeinanlieferungBookedListModuleController extends AbstractFrontendModuleCo
98 98
                         $arrLage = $Lage->fetchEach('title');
99 99
                     }
100 100
 
101
+                    // Compute unit display fields for frontend list
102
+                    $unitTitle = $GLOBALS['TL_LANG']['MSC']['wa_unit_base'] ?? 'Behälter';
103
+                    $unitAmountDisplay = (int) $booking->behaelter;
104
+                    if (!empty($booking->unit_id) && (int)$booking->unit_id > 0) {
105
+                        $unitModel = \vonRotenberg\WeinanlieferungBundle\Model\WeinanlieferungUnitModel::findByPk((int)$booking->unit_id);
106
+                        if (null !== $unitModel) {
107
+                            $unitTitle = (string)$unitModel->title;
108
+                        }
109
+                        $unitAmountDisplay = (int) ($booking->unit_amount ?: 0);
110
+                        if ($unitAmountDisplay <= 0) {
111
+                            $mult = (int) ($unitModel ? $unitModel->multiplier : 0);
112
+                            $unitAmountDisplay = $mult > 0 ? max(1, (int) ($booking->behaelter / $mult)) : (int) $booking->behaelter;
113
+                        }
114
+                    }
115
+
101 116
                     $arrData['days'][$day->dayBegin][] = array_merge($booking->row(), [
102 117
                         'sorte'              => $arrSorten,
103 118
                         'slot'  => array_merge($Slot->row(),[
... ...
@@ -106,6 +121,8 @@ class WeinanlieferungBookedListModuleController extends AbstractFrontendModuleCo
106 121
                         'standort' => $strStandort,
107 122
                         'ernteart' => $arrErnteart,
108 123
                         'lage' => $arrLage,
124
+                        'unit_title' => $unitTitle,
125
+                        'unit_amount_display' => $unitAmountDisplay,
109 126
                     ]);
110 127
                 }
111 128
             }
... ...
@@ -261,9 +261,10 @@ class WeinanlieferungReservationContainerListener
261 261
 
262 262
             // Only perform validation if the booking is not in the past
263 263
             if (!$isInPast) {
264
-                // Check if the number of container numbers matches the number of booked containers
265
-                if (count($behaelterNumbers) != $reservation->behaelter) {
266
-                    throw new \Exception('Die Anzahl der Behälternummern muss mit der Anzahl der gebuchten Behälter übereinstimmen.');
264
+                // Check if the number of container numbers matches the number of required check-ins (unit amount or base behaelter)
265
+                $expectedCount = ($reservation->unit_amount ?? 0) > 0 ? (int)$reservation->unit_amount : (int)$reservation->behaelter;
266
+                if (count($behaelterNumbers) != $expectedCount) {
267
+                    throw new \Exception('Die Anzahl der Behälternummern muss mit der Anzahl der gebuchten Einheiten übereinstimmen.');
267 268
                 }
268 269
 
269 270
                 // Filter out the special value 9999 ("Nummer nicht bekannt") for duplicate check
270 271
new file mode 100644
... ...
@@ -0,0 +1,20 @@
1
+<?php
2
+
3
+declare(strict_types=1);
4
+
5
+/*
6
+ * This file is part of contao-weinanlieferung-bundle.
7
+ *
8
+ * (c) vonRotenberg
9
+ *
10
+ * @license commercial
11
+ */
12
+
13
+namespace vonRotenberg\WeinanlieferungBundle\Model;
14
+
15
+use Contao\Model;
16
+
17
+class WeinanlieferungUnitModel extends Model
18
+{
19
+    protected static $strTable = 'tl_vr_wa_unit';
20
+}