diff --git a/README.md b/README.md index fa4a156..5090325 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ Android (AdMob/File picker/Share). Nessun account, nessun cloud, come l'original | Backup e ripristino (JSON dell'app Android) | fatto | | Scelta lingua a runtime (4 lingue) | fatta | | Introduzione (primo avvio e dalle impostazioni) | fatta | -| Calendario delle consegne | da fare | +| Calendario delle consegne | fatto | ## Struttura @@ -44,6 +44,7 @@ src/datastore.h/.cpp # dati in un JSON in AppConfigLocation + backup nel qml/harbour-henflow.qml # ApplicationWindow: initialPage + cover qml/pages/MainPage.qml # Home: produzione stimata, coda ordini, menu (ordine, clienti, impostazioni) qml/pages/OrderPage.qml # nuovo/modifica ordine con anteprima della data di consegna +qml/pages/CalendarPage.qml # calendario delle consegne: griglia del mese + ordini del giorno qml/pages/SettingsPage.qml # indice delle impostazioni qml/pages/BreedsPage.qml # razze disponibili (attiva/disattiva con conferma) qml/pages/CoopPage.qml # mio pollaio: conteggi e produzione per razza @@ -135,7 +136,7 @@ scritto esattamente come lo esporta l'app Android. | `OnboardingPage` | 4 passi introduttivi | `IntroPage` (primo avvio + dalle impostazioni) | | `ModeSelectionPage` | modalita' allevatore / cliente | non portata: solo allevatore, come deciso | | `HomePage` | card produzione stimata, ordini con stato e azioni, FAB *New order* | `MainPage` + `OrderPage` | -| `HomePage` tab *Calendar* | calendario delle prenotazioni | da fare | +| `HomePage` tab *Calendar* | calendario delle prenotazioni | `CalendarPage` | | `SettingsPage` tab *Available breeds* | catalogo con foto e spunta, conferma alla disattivazione | `BreedsPage` | | `SettingsPage` tab *My coop* | razze allevate, galline registrate/in deposizione, produzione | `CoopPage` + `BreedDetailPage` | | `BreedDetailPage` + `AddHenDialog` | galline della razza, eta' in mesi, stato deposizione | `BreedDetailPage` + `HenPage` (data con tre selettori) | @@ -166,9 +167,7 @@ git archive --format=tar.gz --prefix=harbour-henflow-0.2.0/ -o harbour-henflow-0 ## Aperti -1. **Calendario delle consegne** (il tab *Calendar* dell'originale): griglia del - mese con gli ordini per giorno e l'elenco del giorno scelto. -2. **Verifica su device**: la UI Silica non e' verificabile senza SDK/dispositivo +1. **Verifica su device**: la UI Silica non e' verificabile senza SDK/dispositivo (qui si verificano motore, traduzioni, catalogo, livello dati, chiavi e sintassi della QML, ma non il comportamento a schermo). @@ -185,3 +184,11 @@ git archive --format=tar.gz --prefix=harbour-henflow-0.2.0/ -o harbour-henflow-0 non ha il plugin webp. Riconvertito in JPEG. - Niente annunci (AdMob non esiste su Sailfish) e niente `share_plus`/`file_picker`: export/import JSON in Download. +- Il tab *Calendar* ha i messaggi del dialogo di consegna **scritti a mano in + italiano** ("Conferma Consegna", ...): non tradotti in nessuna delle 4 lingue. + Nel porting la consegna si segna con l'interruttore della riga, come nella Home. +- Gli ordini dell'app Android hanno anche `clientNote` e `source`: il ricalcolo + della coda ricopia **tutti** i campi, cosi' un ordine che arriva dall'Android + non perde la nota del cliente (c'e' un test apposta). +- La consegna scala le uova dal magazzino della razza, come nell'originale; nel + porting annullarla le rimette (l'originale non prevedeva l'annulla). diff --git a/qml/js/henflow.js b/qml/js/henflow.js index d4efab9..151eda6 100644 --- a/qml/js/henflow.js +++ b/qml/js/henflow.js @@ -137,15 +137,26 @@ function accrueStock(stock, breedSettings, catalog, daysPassed) { // --------------------------------------------------------- prenotazioni function cloneReservation(r) { - return { - id: r.id, - customerName: r.customerName, - breedId: r.breedId, - eggsRequested: r.eggsRequested, - createdAt: r.createdAt, - deliveryDate: r.deliveryDate, - isDelivered: r.isDelivered - }; + // Copia TUTTI i campi: l'ordine dell'app Android ne ha anche di piu' + // (source, clientNote) e il ricalcolo delle date non deve perderli. + var out = {}, k; + for (k in r) { + if (r.hasOwnProperty(k)) out[k] = r[k]; + } + return out; +} + +// Consegna: l'app originale, quando un ordine viene segnato come consegnato, +// SCALA le uova dal magazzino di quella razza; annullando la consegna le +// rimette. Restituisce un magazzino nuovo (l'input non viene toccato). +function applyDelivery(stock, reservation, delivered) { + var out = {}, k; + for (k in stock) { + if (stock.hasOwnProperty(k)) out[k] = stock[k]; + } + var eggs = reservation.eggsRequested || 0; + out[reservation.breedId] = (out[reservation.breedId] || 0) + (delivered ? -eggs : eggs); + return out; } function pendingEggs(reservations, breedId) { @@ -268,6 +279,7 @@ if (typeof module !== "undefined" && module.exports) { isProductive: isProductive, accrueStock: accrueStock, pendingEggs: pendingEggs, + applyDelivery: applyDelivery, estimateDeliveryDate: estimateDeliveryDate, refreshDeliveryDates: refreshDeliveryDates, statusKey: statusKey, diff --git a/qml/pages/CalendarPage.qml b/qml/pages/CalendarPage.qml new file mode 100644 index 0000000..08f03d5 --- /dev/null +++ b/qml/pages/CalendarPage.qml @@ -0,0 +1,407 @@ +import QtQuick 2.2 +import Sailfish.Silica 1.0 +import "../js/henflow.js" as HF +import "../js/i18n.js" as I18N +import "../js/catalog.js" as Catalog + +// Calendario delle consegne (il tab *Calendar* della Home dell'originale): +// griglia del mese con un puntino sui giorni che hanno ordini e, sotto, +// l'elenco degli ordini del giorno scelto. +// Nomi di mesi e giorni nella lingua del TELEFONO (Qt.locale()), come faceva +// TableCalendar nell'app Flutter: non sono nelle 126 chiavi dell'app. +// Tutto sta dentro la lista (griglia compresa): su questa Silica e' il layout +// che si comporta bene con i menu a tendina. +Page { + id: page + allowedOrientations: Orientation.All + + property string lang: "en" + property var reservations: [] + property var orders: [] + property string selectedIso: "" + property int viewYear: 0 + property int viewMonth: 1 + property string todayIso: "" + property string monthTitle: "" + property var weekdayNames: [] + property bool ready: false + + // Larghezza di una cella del calendario: 7 colonne piene. + readonly property real cellWidth: width / 7 + readonly property real cellHeight: Theme.itemSizeSmall + + function tr(key, arg) { return I18N.t(key, arg, page.lang) } + + ListModel { id: gridModel } + ListModel { id: ordersModel } + + function isoOf(y, m, d) { + return HF.isoDay(new Date(y, m - 1, d)) + } + + function countFor(iso) { + var n = 0 + for (var i = 0; i < page.reservations.length; i++) { + var d = HF.parseIso(page.reservations[i].deliveryDate) + if (d && HF.isoDay(d) === iso) + n++ + } + return n + } + + function buildWeekdays() { + var names = [] + for (var i = 1; i <= 7; i++) + names.push(Qt.locale().dayName(i, Locale.ShortFormat)) + page.weekdayNames = names + } + + function updateTitle() { + page.monthTitle = Qt.locale().monthName(page.viewMonth, Locale.LongFormat) + + " " + page.viewYear + } + + function reload() { + page.lang = I18N.langFor(appSettings.language, Qt.locale().name) + page.reservations = appData.reservations() + + var today = HF.todayMidnight() + page.todayIso = HF.isoDay(today) + if (page.viewYear === 0) { + page.viewYear = today.getFullYear() + page.viewMonth = today.getMonth() + 1 + } + if (page.selectedIso.length === 0) + page.selectedIso = page.todayIso + + page.buildWeekdays() + page.updateTitle() + page.buildGrid() + page.buildOrders() + } + + function buildGrid() { + gridModel.clear() + + var first = new Date(page.viewYear, page.viewMonth - 1, 1) + // settimana che inizia di lunedi: getDay() da' 0 = domenica + var offset = (first.getDay() + 6) % 7 + var daysInMonth = new Date(page.viewYear, page.viewMonth, 0).getDate() + + for (var i = 0; i < offset; i++) + gridModel.append({ "day": 0, "iso": "", "count": 0, "today": false, "selected": false }) + + for (var d = 1; d <= daysInMonth; d++) { + var iso = page.isoOf(page.viewYear, page.viewMonth, d) + gridModel.append({ + "day": d, + "iso": iso, + "count": page.countFor(iso), + "today": iso === page.todayIso, + "selected": iso === page.selectedIso + }) + } + + while (gridModel.count % 7 !== 0) + gridModel.append({ "day": 0, "iso": "", "count": 0, "today": false, "selected": false }) + } + + function buildOrders() { + ordersModel.clear() + var today = HF.todayMidnight() + for (var i = 0; i < page.reservations.length; i++) { + var r = page.reservations[i] + var d = HF.parseIso(r.deliveryDate) + if (!d || HF.isoDay(d) !== page.selectedIso) + continue + + var days = HF.dayDiff(today, d) + var status + if (r.isDelivered) status = page.tr("ready_delivery") + else if (days < 0) status = page.tr("expired") + else if (days === 0) status = page.tr("today") + else if (days === 1) status = page.tr("in_day") + else status = page.tr("in_days", days) + + var breed = Catalog.breedFromId(r.breedId) + ordersModel.append({ + "id": r.id, + "customer": r.customerName, + "breed": breed ? page.tr(breed.name) : r.breedId, + "eggs": r.eggsRequested, + "note": r.clientNote ? r.clientNote : "", + "status": status, + "late": (!r.isDelivered && days < 0), + "delivered": r.isDelivered + }) + } + } + + function shiftMonth(delta) { + var m = page.viewMonth + delta + var y = page.viewYear + if (m < 1) { + m = 12 + y-- + } else if (m > 12) { + m = 1 + y++ + } + page.viewMonth = m + page.viewYear = y + page.updateTitle() + page.buildGrid() + } + + function selectDay(iso) { + if (iso.length === 0) + return + page.selectedIso = iso + page.buildGrid() + page.buildOrders() + } + + function goToday() { + var today = HF.todayMidnight() + page.viewYear = today.getFullYear() + page.viewMonth = today.getMonth() + 1 + page.selectedIso = page.todayIso + page.updateTitle() + page.buildGrid() + page.buildOrders() + } + + function openOrder(index) { + var id = page.orders[index].id + var list = appData.reservations() + var reservation = null + for (var i = 0; i < list.length; i++) { + if (list[i].id === id) + reservation = list[i] + } + pageStack.push(Qt.resolvedUrl("OrderPage.qml"), + { "reservation": reservation, + "onSaved": function () { page.reload() } }) + } + + // Consegnato: come l'originale, segnare la consegna SCALA le uova dal + // magazzino (e annullarla le rimette). + function toggleDelivered(index) { + var id = page.orders[index].id + var list = appData.reservations().slice() + for (var i = 0; i < list.length; i++) { + if (list[i].id === id) { + list[i].isDelivered = !list[i].isDelivered + appData.saveWarehouseStock( + HF.applyDelivery(appData.warehouseStock(), list[i], list[i].isDelivered)) + } + } + appData.saveReservations(list) + page.reload() + } + + Component.onCompleted: page.reload() + + onStatusChanged: { + if (status !== PageStatus.Active) + return + if (page.ready) + page.reload() + page.ready = true + } + + SilicaListView { + id: dayList + anchors.fill: parent + model: ordersModel + + header: Column { + width: dayList.width + + PageHeader { title: page.tr("calendar") } + + // navigazione fra i mesi + Row { + x: Theme.horizontalPageMargin + width: dayList.width - 2 * Theme.horizontalPageMargin + spacing: Theme.paddingSmall + + IconButton { + id: prevButton + icon.source: "image://theme/icon-m-left" + onClicked: page.shiftMonth(-1) + } + + Label { + anchors.verticalCenter: prevButton.verticalCenter + width: parent.width - 2 * (prevButton.width + parent.spacing) + horizontalAlignment: Text.AlignHCenter + text: page.monthTitle + color: Theme.highlightColor + font.pixelSize: Theme.fontSizeSmall + truncationMode: TruncationMode.Fade + } + + IconButton { + icon.source: "image://theme/icon-m-right" + onClicked: page.shiftMonth(1) + } + } + + // intestazione dei giorni della settimana + Row { + x: Theme.horizontalPageMargin + width: dayList.width - 2 * Theme.horizontalPageMargin + + Repeater { + model: page.weekdayNames.length + delegate: Label { + width: page.cellWidth + horizontalAlignment: Text.AlignHCenter + text: page.weekdayNames[index] + color: Theme.secondaryColor + font.pixelSize: Theme.fontSizeExtraSmall + truncationMode: TruncationMode.Fade + } + } + } + + // griglia del mese + Grid { + x: Theme.horizontalPageMargin + width: dayList.width - 2 * Theme.horizontalPageMargin + height: (gridModel.count / 7) * page.cellHeight + columns: 7 + + Repeater { + model: gridModel + + delegate: BackgroundItem { + id: cell + width: page.cellWidth + height: page.cellHeight + + onClicked: page.selectDay(model.iso) + + Rectangle { + anchors.centerIn: parent + width: Math.min(parent.width, parent.height) - Theme.paddingMedium + height: width + radius: width / 2 + color: model.selected ? Theme.highlightBackgroundColor + : (model.today ? Theme.rgba(Theme.highlightBackgroundColor, 0.35) + : "transparent") + + Label { + anchors.centerIn: parent + text: model.day > 0 ? String(model.day) : "" + color: model.selected ? Theme.highlightColor + : (model.day > 0 ? Theme.primaryColor : Theme.secondaryColor) + font.pixelSize: Theme.fontSizeSmall + } + } + + // puntino: quel giorno ha ordini + Rectangle { + visible: model.count > 0 + anchors { + horizontalCenter: parent.horizontalCenter + bottom: parent.bottom + bottomMargin: Theme.paddingSmall + } + width: Theme.paddingSmall + height: width + radius: width / 2 + color: Theme.highlightColor + } + } + } + } + + Item { width: parent.width; height: Theme.paddingMedium } + + SectionHeader { + text: HF.formatDay(page.selectedIso) + " (" + ordersModel.count + ")" + } + } + + delegate: ListItem { + id: orderItem + contentHeight: orderText.height + Theme.paddingMedium + + Column { + id: orderText + x: Theme.horizontalPageMargin + y: Theme.paddingMedium + width: parent.width - 2 * Theme.horizontalPageMargin - deliveredSwitch.width + - Theme.paddingMedium + spacing: Theme.paddingSmall + + Label { + width: parent.width + text: model.customer + color: orderItem.highlighted ? Theme.highlightColor : Theme.primaryColor + truncationMode: TruncationMode.Fade + } + + Label { + width: parent.width + text: page.tr("breed") + ": " + model.breed + " ยท " + page.tr("eggs_req") + ": " + model.eggs + color: Theme.secondaryColor + font.pixelSize: Theme.fontSizeExtraSmall + truncationMode: TruncationMode.Fade + } + + Label { + width: parent.width + text: model.status + color: model.late ? Theme.errorColor : Theme.secondaryColor + font.pixelSize: Theme.fontSizeExtraSmall + truncationMode: TruncationMode.Fade + } + + Label { + width: parent.width + visible: model.note.length > 0 + text: page.tr("client_note") + ": " + model.note + color: Theme.secondaryColor + font.pixelSize: Theme.fontSizeExtraSmall + wrapMode: Text.Wrap + } + } + + Switch { + id: deliveredSwitch + anchors { + right: parent.right + rightMargin: Theme.horizontalPageMargin + verticalCenter: parent.verticalCenter + } + checked: model.delivered + onClicked: page.toggleDelivered(index) + } + + onClicked: page.openOrder(index) + } + + ViewPlaceholder { + enabled: ordersModel.count === 0 + text: page.tr("no_reservations") + } + + PullDownMenu { + MenuItem { + text: page.tr("today") + onClicked: page.goToday() + } + MenuItem { + text: page.tr("new_order") + onClicked: pageStack.push(Qt.resolvedUrl("OrderPage.qml"), + { "reservation": null, + "onSaved": function () { page.reload() } }) + } + } + + VerticalScrollDecorator {} + } +} diff --git a/qml/pages/MainPage.qml b/qml/pages/MainPage.qml index 9f2eeea..fdda69c 100644 --- a/qml/pages/MainPage.qml +++ b/qml/pages/MainPage.qml @@ -111,6 +111,10 @@ Page { if (index < 0 || index >= list.length) return list[index].isDelivered = !list[index].isDelivered + // Come l'originale: segnare la consegna SCALA le uova dal magazzino + // (annullarla le rimette). + appData.saveWarehouseStock( + HF.applyDelivery(appData.warehouseStock(), list[index], list[index].isDelivered)) page.reservations = list appData.saveReservations(list) page.rebuildList() @@ -281,6 +285,10 @@ Page { text: page.tr("customers_list") onClicked: pageStack.push(Qt.resolvedUrl("CustomersPage.qml")) } + MenuItem { + text: page.tr("calendar") + onClicked: pageStack.push(Qt.resolvedUrl("CalendarPage.qml")) + } MenuItem { text: page.tr("settings") onClicked: pageStack.push(Qt.resolvedUrl("SettingsPage.qml")) diff --git a/qml/pages/OrderPage.qml b/qml/pages/OrderPage.qml index 48fc802..e353c82 100644 --- a/qml/pages/OrderPage.qml +++ b/qml/pages/OrderPage.qml @@ -43,6 +43,10 @@ Page { if (page.selected < 0 && list.length > 0) page.selected = 0 + // Come l'originale: un nuovo ordine parte da 6 uova. + if (!page.reservation && eggsField.text.length === 0) + eggsField.text = "6" + page.updatePreview() } diff --git a/tests/engine.test.js b/tests/engine.test.js index 557388e..dcc0073 100644 --- a/tests/engine.test.js +++ b/tests/engine.test.js @@ -120,5 +120,24 @@ check("formatDay su stringa vuota", HF.formatDay(""), ""); check("nowIso in formato ISO", /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.000$/.test(HF.nowIso()), true); check("newId unico", HF.newId() !== HF.newId(), true); +// --- fedelta' dei campi degli ordini (app Android) ------------------------ +var withNote = { id: "n1", customerName: "Anna", breedId: "marans", eggsRequested: 2, + createdAt: "2026-04-25T09:00:00.000", deliveryDate: "2026-04-28T00:00:00.000", + isDelivered: false, source: "client", clientNote: "guscio chiaro" }; +var kept = HF.refreshDeliveryDates([withNote], {}, BREEDS, CATALOG, TODAY); +check("nota del cliente conservata nel ricalcolo delle date", kept[0].clientNote, "guscio chiaro"); +check("origine dell'ordine conservata", kept[0].source, "client"); + +// --- consegna: le uova escono dal magazzino ------------------------------- +var stock0 = { "marans": 10, "araucana": 3 }; +var afterDelivery = HF.applyDelivery(stock0, { breedId: "marans", eggsRequested: 4 }, true); +check("consegna: uova scalate dal magazzino della razza", afterDelivery.marans, 6); +check("consegna: le altre razze non si toccano", afterDelivery.araucana, 3); +check("consegna: magazzino di partenza non modificato", stock0.marans, 10); +check("consegna annullata: uova rimesse nel magazzino", + HF.applyDelivery(afterDelivery, { breedId: "marans", eggsRequested: 4 }, false).marans, 10); +check("consegna su razza senza scorte: il magazzino va sotto zero", + HF.applyDelivery({}, { breedId: "olive", eggsRequested: 3 }, true).olive, -3); + console.log(failures === 0 ? "\ntutti i controlli superati" : "\n" + failures + " controlli falliti"); process.exit(failures === 0 ? 0 : 1);